v0.17.0 — Wave 3: sandbox DB, DB isolation, mobile UI, cloud auth, X account, roadmap engine (#303)

* feat(sandbox): throwaway per-agent Postgres/Redis sandbox containers

Orchestrator-provisioned sibling containers per agent spawn
(SandboxProvisioner, roboco/runtime/sandbox.py). Per-project opt-in via
projects.sandbox_services (migration 057); master switch
ROBOCO_SANDBOX_DB_ENABLED, default-off, armed in the NAS compose only.

When active, ROBOCO_TEST_DB_* / ROBOCO_TEST_REDIS_* point at the sandbox
and the prod-creds gate-env injection is suppressed (sandbox replaces,
never coexists). Sandbox lifetime tracks the agent container: teardown at
every removal path, orphan janitor at startup + each reaper tick with a
grace window for mid-flight spawns. The pre-spawn stale-clear spares the
just-provisioned sandbox; provision pre-clears stale same-named
containers from a crash-missed teardown.

Panel: per-project sandbox-service switches in the edit dialog + feature
flag card entry.

* docs: CLAUDE.md entry for the sandboxed dev DB/Redis subsystem

* feat(security): isolate prod Postgres/Redis from agent containers (roboco_data network)

Second user-defined bridge roboco_data carries postgres+redis only; the
orchestrator is multi-homed (default + data). Spawned agents and their
sandbox sidecars stay on roboco_default and can no longer resolve or
reach roboco-postgres:5432 / roboco-redis:6379 (redis has no auth —
membership is its only containment). Normal bridge, so host-published
ports (15432/16379) keep working. Applied to both build composes and
the registry compose; docker-compose.yml re-synced byte-identical with
docker-compose.yaml (it had drifted by the sandbox flag block).

ROBOCO_DB_NETWORK_ISOLATED (config default false, armed alongside the
topology) suppresses the legacy _append_gate_env prod-creds injection:
under isolation those creds dead-end, and unreachable creds are worse
than none. DB-needing projects opt into sandbox_services instead. The
flag is deliberately not a panel feature flag - it must travel with the
compose networks: stanzas.

Preserved by construction: agent<->agent A2A and orchestrator->agent SDK
polls on :9000, MCP->orchestrator on :8000, ollama reachability, docker
exec/inspect (daemon socket), host port publishing.

* feat(panel): full mobile responsiveness pass

Shared primitives: useIsMobile (useSyncExternalStore, hydration-safe,
memoized matchMedia subscribe), ResponsiveTable table->card switch below
md (single subtree mounted, no duplicated interactive rows), scrollable
snap TabsList in the base primitive (justify-center-safe so the first
tab stays reachable on overflow), persistent md:hidden bottom tab bar
(Overview/Tasks/Kanban/Chat, safe-area padded).

Applied: card lists for tasks/projects/products/work-sessions/sessions
+ the three raw metrics tables; CEO approval queue / release proposal /
playbook review action rows stack on narrow; command-center reorders
approvals above the fold on mobile; task-header metadata wraps;
Communications + A2A become URL-driven single-pane drill-downs below lg
(fixes the unconstrained-height ScrollArea bug) with dvh heights;
recharts label density/radius adapts via useIsMobile; git diff viewer
gets mobile font + wrap toggle; vh->dvh sweep; chat composers get
safe-area-inset padding; dashboard main p-4 md:p-6 + pb-20 for the bar.

Verified at 375px on the built app: bottom bar, drawer, approval-first
overview, swipeable kanban tab strip. All gates green (eslint, tsc,
vitest 249, next build 24/24 routes).

* feat(auth): cloud auth via FastAPI Users (default-off, single-user cookie session)

ROBOCO_CLOUD_AUTH_ENABLED (default off) lets the panel/API be exposed
beyond localhost without changing the CEO's local no-login flow while
off — get_agent_context and the WS gate are byte-for-byte unchanged in
off-mode. On: header-trust dies for humans — any agent-role claim (ceo
or a privileged PM/board role) with no valid HMAC token or session
cookie is 401, closing the header-spoof hole on the host-published
:8000 port for every role. The agent-fleet HMAC path and the system
self-PATCH keep working unmodified in both modes.

Single seeded CEO user (migration 058 users table, UserTable), no
registration router — idempotent env-driven upsert at startup by PK.
Cookie transport (httponly/secure/samesite=lax) + a JWTStrategy bound
to a fingerprint of the current password hash (rotating the password
invalidates every prior session). Sliding 30-day session: every
authenticated request re-mints the cookie, so an active session never
expires — no unexpected logouts.

Panel: (auth)/login page + proxy.ts (Next 16 rename of middleware; probes
/auth/status over the docker-internal URL, fails open to off) gate the
dashboard; client.ts gets withCredentials + 401->/login. nginx unchanged.

Review hardening: broadened the on-mode rejection from ceo-only to every
non-CEO role without a valid token (was only closed when
ROBOCO_AGENT_AUTH_REQUIRED was also armed); Next-16 proxy.ts rename to
clear the middleware deprecation warning.

* feat(x): RoboCo X account engine — HoM drafts, per-post CEO approval (default-off)

ROBOCO_X_ENGINE_ENABLED (default off, inert without creds). Mirrors the
ReleaseManagerEngine held-artifact shape: XEngine drafts a post when a
release publishes (via a draft_release_post seam on ReleaseProposalService
.approve) and drafts replies to meaningful mentions (dedicated poll loop,
x_seen_mentions dedup ledger, per-cycle/open caps). Drafting is
local-model-only, clamped to 280 chars. Nothing auto-posts — every tweet
is a held task (source x_post/x_reply, confirmed_by_human=False,
Secretary-owned, dispatcher-skipped) the CEO edits/approves/rejects in a
panel queue.

The four OAuth 1.0a secrets live Fernet-encrypted in a singleton
x_credentials row (migration 059, all-or-nothing, API returns only
has_credentials); decryption is server-side, agents never hold creds or
egress. Hand-rolled OAuth 1.0a HMAC-SHA1 signer, no new dependency;
NullXClient makes the unconfigured path a graceful no-op.

XPostService.approve (CEO-only) is the sole caller of post_tweet.

Review hardening: closed a double-post race — the approve path now
re-reads committed task state inside the Redis lock and commits COMPLETED
before releasing, so a concurrent approve that acquires the lock after the
winner released can't re-post (SET-NX is non-waiting, and the route-level
commit landed after the lock dropped). Added a regression test.

* feat(roadmap): board roadmap engine — PO proposes themed cycles, CEO approves per-item (default-off)

ROBOCO_ROADMAP_ENGINE_ENABLED (default off). Weekly, RoadmapEngine opens
ONE held exploration task (source=board_roadmap, confirmed_by_human=False,
Product-Owner-assigned), deduped to one open cycle. A dedicated one-shot
_dispatch_roadmap_exploration spawns the PO solo (not the two-reviewer
board path, which would also spawn HoM + fire Approve-&-Start). The PO
explores read-only (git/KB/metrics/releases/charter/web) and makes one
propose_roadmap call (PO-only content verb) authoring a themed cycle —
goal + 3-7 item drafts — persisted as a roadmap_cycle marker (no table,
no migration; head stays 059).

The CEO acts per-item in the panel roadmap queue: approve materializes a
BACKLOG task (source=roadmap, no assignee — never auto-starts), reject
records a reason; all-items-terminal completes the exploration task.
RoadmapService is idempotent per item. Dispatchers skip board_roadmap.

Includes a real SQLAlchemy dirty-check fix (deep-copy the JSON marker
before mutating, or the in-place edit + reassign compares equal to its
own baseline and the UPDATE is skipped).

Review hardening: create_task_from_draft now honors a draft-declared
source only from a {prompter, roadmap} whitelist — drafts are
LLM-authored, so an unbounded source could impersonate a privileged
origin (release_manager would even wedge that engine's dedup).

* chore(release): 0.17.0

Wave 3 — six default-off subsystems: sandboxed dev DB/Redis, prod
Postgres/Redis network isolation, full mobile UI pass, cloud auth
(FastAPI Users), the RoboCo X account engine, and the board roadmap
engine. Plus the waves 1+2 work already on master since 0.16.0.

Version bumped across the canonical set (config.py, __init__.py,
pyproject.toml, panel/package.json, uv.lock); CHANGELOG [Unreleased]
cut to [0.17.0]; docs/map delta added.

Compose: every optional feature armed :-true in the NAS composes, OFF
in the user-facing registry compose. Two opt-in exceptions default off
(CLOUD_AUTH — needs email/password/secret + TLS, would otherwise fail
startup; ROUTING_STRICT — fail-closed spawning). DB_NETWORK_ISOLATED
stays on in both (coupled to the roboco_data topology).

* chore(compose): arm cloud_auth + routing_strict ON in the NAS composes

Every feature defaults ON in the NAS composes per policy — these two
were wrongly left off. Both keep the ${VAR:-true} form so the operator
controls the real runtime via .env: cloud auth needs
ROBOCO_CLOUD_AUTH_EMAIL/_PASSWORD/_SECRET + TLS set there before a boot
(else startup fails loud), and routing_strict is fail-closed. Registry
compose keeps both off.

* fix(ci): reflow board.md prose (quality gate) + document v0.17.0 env creds

The roadmap section added hard-wrapped prose that failed the markdown
prose gate; reflowed (token-invariant). Also brought .env.example
current: cloud auth (now armed — needs SECRET or startup fails), routing
strict, the X engine (panel-entered OAuth), and web research.

* fix(ci): reduce cyclomatic complexity of five wave-3 blocks (xenon gate)

The wave-3 subagents introduced C-rank functions the CI xenon gate
rejects (my per-item reviews ran ruff/mypy/pytest but not xenon):
- sandbox.janitor_sweep -> extract _list_labeled_sandboxes /
  _list_live_agent_containers / _prune_grace
- x_client.fetch_mentions -> extract _parse_mention_items
- x_engine.run_cycle -> extract _process_mentions
- orchestrator._dispatch_pm_work -> extract the source-skip into a
  MODULE-level _is_held_ceo_source (module, not method, so the
  wholesale-mocked dispatcher unit tests exercise the real logic)
- auth/seed.ensure_seed_user -> extract _apply_seed_updates (module avg -> A)

Behavior-preserving; full suite green (11902), xenon clean.

* fix(ci): declare pyjwt + fastapi-users-db-sqlalchemy as direct deps (deptry)

The cloud-auth code imports jwt and fastapi_users_db_sqlalchemy directly
but they were only transitive deps (via fastapi-users), which deptry
(quality gate, DEP003) rejects. Declared explicitly; deptry roboco/ clean.
Missed originally because local make quality stopped at earlier gates
before reaching deptry.

* feat(x): gate mention replies behind ROBOCO_X_REPLIES_ENABLED (default off)

Per CEO decision: the X engine should only post about releases by
default. Reading mentions needs a paid X API tier, so the mention-reply
half is now a deliberate opt-in on top of release posting.

New default-off flag x_replies_enabled gates the mentions poll loop
(_x_mentions_poll_loop) and XEngine.run_cycle; release-post drafting
(the release-proposal approve hook) is unaffected and still runs when
x_engine_enabled + credentials are set. Added to FEATURE_FLAGS + the
panel card. Tests: release posting works with replies off; run_cycle +
the poll loop are no-ops with replies off.

* fix: 401 only redirects to /login when cloud auth is on; panel-token strips .env quotes

Two bugs that together dead-ended login in secure mode:
- client.ts redirected to /login on ANY 401, so a mismatched panel
  token (header-trust/secure mode, cloud auth off) bounced the user to a
  login page whose backend route isn't mounted -> 404. Now it probes
  /auth/status (bare fetch, no interceptor re-entry) and only redirects
  when cloud_auth_enabled.
- make panel-token read the .env secret with grep|cut without stripping
  surrounding quotes, so a quoted ROBOCO_AGENT_AUTH_SECRET produced a
  token signed with the quotes included — which never verifies against
  the orchestrator (docker-compose/pydantic unquote the secret). Now
  strips surrounding single/double quotes.

* fix: git-log 500 on '|' in commit message; X queue shows an empty state

- GET /api/git/log 500'd (ValueError: Invalid isoformat) when a commit
  SUBJECT contained a '|' (e.g. the 'curl|sh' lockdown commit): the
  fixed '|' field delimiter let the subject's pipe shift the split so
  author+date collapsed into one field. Switched to \x1f (Unit
  Separator), which can't appear in commit content. Regression test with
  a piped subject.
- The X Post Queue returned null when empty, so there was no visible
  place for the X drafts. It now renders a discoverable empty state
  pointing at Settings -> X credentials.

* docs: bring docs/rag + docs/map current for v0.17.0 (waves 1-3)

Agent-facing RAG corpus and codebase map updated for every feature in
the 0.17.0 span, code-verified:
- wave 3: sandbox DB, DB network isolation, cloud auth, X engine
  (+ x_replies_enabled sub-flag), board roadmap engine — new RAG
  architecture pages + role/tool/config-reference updates; new symbols,
  migrations 057-059, panel surfaces, and the get_agent_context
  dual-path across the map slices.
- waves 1-2: A2A live view + switchboard, prompter memory
  (search_past_tasks), Secretary edit access + PM-lighter scope, the
  PR-gate auto-submit turn cut (ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED).
- correctness fix: api-routes-schemas.md no longer claims the A2A admin
  routes are reachable by any authenticated agent — they carry a
  _require_ceo gate (wave 2c).

docs/internal, _front.md deltas, and the frozen _complete_map.md
snapshot untouched.

* fix(rag): atomic upsert for indexed-doc tracking (kills e2e segfault)

The indexed-document tracking write used check-then-insert in two paths
(IndexedDocumentRepository.upsert_batch and the file-source
_upsert_doc_record). Under concurrent indexing both callers saw no row
and both inserted, so the second violated uq_indexed_doc_source and
poisoned its transaction — surfacing in CI as the intermittent
_checkin_failed SIGSEGV on the failed connection's pool checkin.

Both paths now use INSERT ... ON CONFLICT DO UPDATE against the
constraint: coalesce keeps an existing title/preview when the new value
is empty (matching the old guards) and metadata is jsonb-merged. The
batch dedupes within itself first (ON CONFLICT can't touch a row twice
in one statement). expire_all after the Core upsert keeps same-session
ORM reads consistent with the merged DB row.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 19:24:00 +02:00
committed by GitHub
co-authored by Renn F
parent 12745352aa
commit 3ccc723cd4
179 changed files with 12734 additions and 948 deletions
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { NextRequest } from "next/server";
describe("proxy", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.resetModules();
});
it("passes through when cloud auth is off", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ cloud_auth_enabled: false }),
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(200);
});
it("redirects to /login when cloud auth is on and no session cookie", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ cloud_auth_enabled: true }),
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/login");
});
it("passes through when cloud auth is on and a session cookie is present", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ cloud_auth_enabled: true }),
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const req = new NextRequest("http://localhost:3000/overview", {
headers: { cookie: "roboco_session=abc123" },
});
const res = await proxy(req);
expect(res.status).toBe(200);
});
it("fails open (passes through) when the status probe errors", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(200);
});
it("fails open when the status probe returns a non-ok response", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false }) as unknown as
typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
const { mutateAsync, push, refresh } = vi.hoisted(() => ({
mutateAsync: vi.fn(),
push: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push, refresh }),
}));
vi.mock("@/hooks/use-auth", () => ({
useLogin: () => ({ mutateAsync, isPending: false }),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn() },
}));
import LoginPage from "../page";
describe("LoginPage", () => {
beforeEach(() => {
mutateAsync.mockReset();
push.mockReset();
refresh.mockReset();
});
it("requires both fields before submitting", () => {
render(<LoginPage />);
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Enter your email and password",
);
expect(mutateAsync).not.toHaveBeenCalled();
});
it("logs in and redirects to /overview on success", async () => {
mutateAsync.mockResolvedValue(undefined);
render(<LoginPage />);
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "ceo@example.com" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "hunter2" },
});
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledWith({
email: "ceo@example.com",
password: "hunter2",
});
});
expect(push).toHaveBeenCalledWith("/overview");
});
it("shows the error message on bad credentials without redirecting", async () => {
mutateAsync.mockRejectedValue({
isAxiosError: true,
response: { status: 400, data: { detail: "LOGIN_BAD_CREDENTIALS" } },
});
render(<LoginPage />);
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "ceo@example.com" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "wrong" },
});
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"LOGIN_BAD_CREDENTIALS",
);
});
expect(push).not.toHaveBeenCalled();
});
});
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getErrorMessage } from "@/lib/api/client";
import { useLogin } from "@/hooks/use-auth";
export default function LoginPage() {
const router = useRouter();
const login = useLogin();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError("Enter your email and password");
return;
}
try {
await login.mutateAsync({ email, password });
router.push("/overview");
router.refresh();
} catch (err) {
const message = getErrorMessage(err);
setError(message);
toast.error(message);
}
};
return (
// h-dvh (not h-screen): mobile Safari's dynamic toolbar resizes the
// viewport, and 100vh doesn't track that — dvh does (matches the
// dashboard shell's own convention).
<div className="flex h-dvh items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>RoboCo</CardTitle>
<CardDescription>Sign in to the control panel</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="username"
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<Button type="submit" disabled={login.isPending} className="w-full">
{login.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+161 -122
View File
@@ -25,6 +25,7 @@ import { getAgentDisplayName } from "@/lib/agent-utils";
import { lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
LayoutGrid,
List as ListIcon,
MessagesSquare,
@@ -156,8 +157,21 @@ function A2APageContent() {
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Below `lg` only one pane shows at a time (list/switchboard -> detail with
// a back affordance); at `lg`+ both always show side by side.
const onDetailLevel = !!selectedId || !!peekedPair;
const handleBack = useCallback(() => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/a2a?${qs}` : "/a2a");
}, [router, searchParams]);
return (
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
// h-dvh (not h-vh) and unconditional now (not just lg:+) so the single
// visible mobile pane gets a real height for its internal ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
@@ -194,137 +208,162 @@ function A2APageContent() {
onRetry={() => refetchConversations()}
/>
) : (
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card className="col-span-12 lg:col-span-4 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden -mx-3">
{view === "switchboard" ? (
<A2ASwitchboard
pairs={pairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
onOpenPair={handleOpenPair}
/>
) : (
<A2AConversationList
conversations={conversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
/>
)}
</div>
</CardContent>
</Card>
<>
{/* Mobile-only back affordance — drills back up to the list. */}
{onDetailLevel && (
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
{/* Panel 2: Transcript + composer */}
<Card className="col-span-12 lg:col-span-8 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
{selected ? (
<>
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{getAgentDisplayName(selected.agent_a)}
{" ↔ "}
{getAgentDisplayName(selected.agent_b)}
</span>
<Badge
variant={
selected.status === "active" ? "default" : "secondary"
}
className="text-xs"
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
onDetailLevel ? "hidden" : "flex",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
{selected.status}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))} ago
</span>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<A2ATranscript
messages={messages}
isLoading={loadingMessages}
</div>
<div className="flex-1 overflow-hidden -mx-3">
{view === "switchboard" ? (
<A2ASwitchboard
pairs={pairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
onOpenPair={handleOpenPair}
/>
</div>
{/* Reply composer. The backend's reply route rejects with
) : (
<A2AConversationList
conversations={conversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
/>
)}
</div>
</CardContent>
</Card>
{/* Panel 2: Transcript + composer */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-8 lg:flex",
onDetailLevel ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
{selected ? (
<>
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{getAgentDisplayName(selected.agent_a)}
{" ↔ "}
{getAgentDisplayName(selected.agent_b)}
</span>
<Badge
variant={
selected.status === "active" ? "default" : "secondary"
}
className="text-xs"
>
{selected.status}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))} ago
</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<A2ATranscript
messages={messages}
isLoading={loadingMessages}
/>
</div>
{/* Reply composer. The backend's reply route rejects with
400 exactly when the watched conversation has no task
link (replies ride the gateway send path, which requires
one), so a task-less conversation is read-only — say why
instead of letting the send bounce. Status does NOT gate
the composer: the CEO's reply lands in their own direct
thread with the participant, not in this conversation. */}
<div className="shrink-0 border-t -mx-3">
{selected.task_id ? (
<A2AReplyComposer
key={selected.id}
conversationId={selected.id}
agentA={selected.agent_a}
agentB={selected.agent_b}
lastSender={lastSender}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped to a
task).
</div>
)}
<div className="shrink-0 border-t -mx-3">
{selected.task_id ? (
<A2AReplyComposer
key={selected.id}
conversationId={selected.id}
agentA={selected.agent_a}
agentB={selected.agent_b}
lastSender={lastSender}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped to
a task).
</div>
)}
</div>
</>
) : peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
</>
) : peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<EmptyPanel
icon={MessagesSquare}
message="Select a conversation to watch it live"
/>
)}
</CardContent>
</Card>
</div>
) : (
<EmptyPanel
icon={MessagesSquare}
message="Select a conversation to watch it live"
/>
)}
</CardContent>
</Card>
</div>
</>
)}
</div>
);
@@ -335,7 +374,7 @@ export default function A2APage() {
return (
<Suspense
fallback={
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
@@ -177,7 +177,7 @@ function SessionDetailContent() {
}
return (
<div className="flex flex-col h-[calc(100vh-7rem)]">
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
+121 -71
View File
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { OfflineState } from "@/components/ui/offline-state";
import { cn } from "@/lib/utils";
import {
Hash,
Lock,
@@ -22,6 +23,7 @@ import {
RefreshCw,
Folder,
MessageCircle,
ArrowLeft,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
@@ -317,10 +319,28 @@ function CommunicationsPageContent() {
[updateParams],
);
// Below `lg` only one pane is shown at a time (list -> detail drill-down);
// at `lg`+ all three always show side by side (mobilePane classes below
// are overridden by their own `lg:flex`).
const handleBack = useCallback(() => {
if (groupId) {
updateParams({ group: null });
} else if (channelId) {
updateParams({ channel: null, group: null });
}
}, [channelId, groupId, updateParams]);
const selectedChannel = channels?.find((c) => c.id === channelId);
const showChannelsPane = !channelId;
const showGroupsPane = !!channelId && !groupId;
const showSessionsPane = !!channelId && !!groupId;
return (
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
// h-dvh (not h-vh): mobile Safari's dynamic toolbar resizes the viewport,
// and this height is unconditional now (not just lg:+) so the single
// visible mobile pane also gets a real height for its ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
@@ -342,78 +362,108 @@ function CommunicationsPageContent() {
onRetry={() => refetch()}
/>
) : (
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
{/* Panel 1: Channels */}
<Card className="col-span-12 lg:col-span-3 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Channels</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<ChannelList
channels={channels || []}
selectedId={channelId}
onSelect={handleSelectChannel}
isLoading={isLoading}
/>
</div>
</CardContent>
</Card>
<>
{/* Mobile-only back affordance — drills back up one level. */}
{channelId && (
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
{/* Panel 2: Groups */}
<Card className="col-span-12 lg:col-span-3 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Groups</span>
{selectedChannel && (
<Badge
variant="outline"
className="ml-auto text-xs font-normal"
>
{selectedChannel.name}
</Badge>
)}
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId ? (
<GroupList
channelId={channelId}
selectedId={groupId}
onSelect={handleSelectGroup}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Channels */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
showChannelsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Channels</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<ChannelList
channels={channels || []}
selectedId={channelId}
onSelect={handleSelectChannel}
isLoading={isLoading}
/>
) : (
<EmptyPanel icon={Folder} message="Select a channel" />
)}
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
{/* Panel 3: Sessions */}
<Card className="col-span-12 lg:col-span-6 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Sessions</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId && groupId ? (
<SessionList channelId={channelId} groupId={groupId} />
) : (
<EmptyPanel
icon={MessageSquare}
message={
channelId
? "Select a group"
: "Select a channel and group"
}
/>
)}
</div>
</CardContent>
</Card>
</div>
{/* Panel 2: Groups */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
showGroupsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Groups</span>
{selectedChannel && (
<Badge
variant="outline"
className="ml-auto text-xs font-normal"
>
{selectedChannel.name}
</Badge>
)}
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId ? (
<GroupList
channelId={channelId}
selectedId={groupId}
onSelect={handleSelectGroup}
/>
) : (
<EmptyPanel icon={Folder} message="Select a channel" />
)}
</div>
</CardContent>
</Card>
{/* Panel 3: Sessions */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-6 lg:flex",
showSessionsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Sessions</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId && groupId ? (
<SessionList channelId={channelId} groupId={groupId} />
) : (
<EmptyPanel
icon={MessageSquare}
message={
channelId
? "Select a group"
: "Select a channel and group"
}
/>
)}
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
);
@@ -424,7 +474,7 @@ export default function CommunicationsPage() {
return (
<Suspense
fallback={
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
+7 -2
View File
@@ -1,6 +1,7 @@
import { Suspense } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
import { ScrollRestoration } from "@/components/scroll-restoration";
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
@@ -10,18 +11,22 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden">
// h-dvh (not h-screen/100vh): mobile Safari's dynamic toolbar resizes the
// viewport, and 100vh doesn't track that — dvh does.
<div className="flex h-dvh overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<RateLimitBanner />
<main className="flex-1 overflow-auto bg-muted/30 p-6">
{/* pb-20 clears the fixed BottomTabBar on mobile; md+ has no bar. */}
<main className="flex-1 overflow-auto bg-muted/30 p-4 pb-20 md:p-6">
<Suspense fallback={null}>
<ScrollRestoration />
</Suspense>
{children}
</main>
</div>
<BottomTabBar />
</div>
);
}
+91 -39
View File
@@ -22,6 +22,12 @@ import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { DeliveryTabContent } from "@/components/metrics/delivery-tab";
import { ScorecardsTabContent } from "@/components/metrics/scorecards-tab";
import {
@@ -635,30 +641,56 @@ function RoleUsageTable({ data, isLoading }: RoleUsageTableProps) {
No usage recorded yet.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="pb-1 font-medium">Role</th>
<th className="pb-1 font-medium text-right">Cost</th>
<th className="pb-1 font-medium text-right">Cache hit</th>
<th className="pb-1 font-medium text-right">%</th>
</tr>
</thead>
<tbody>
{data.map((r) => (
<tr key={r.role} className="border-t">
<td className="py-1 font-mono text-xs">{r.role}</td>
<td className="py-1 text-right">${r.cost_usd.toFixed(4)}</td>
<td className="py-1 text-right">
{(r.cache_hit_rate * 100).toFixed(1)}%
</td>
<td className="py-1 text-right text-muted-foreground">
{r.pct_of_total.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
<ResponsiveTable
table={
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="pb-1 font-medium">Role</th>
<th className="pb-1 font-medium text-right">Cost</th>
<th className="pb-1 font-medium text-right">Cache hit</th>
<th className="pb-1 font-medium text-right">%</th>
</tr>
</thead>
<tbody>
{data.map((r) => (
<tr key={r.role} className="border-t">
<td className="py-1 font-mono text-xs">{r.role}</td>
<td className="py-1 text-right">
${r.cost_usd.toFixed(4)}
</td>
<td className="py-1 text-right">
{(r.cache_hit_rate * 100).toFixed(1)}%
</td>
<td className="py-1 text-right text-muted-foreground">
{r.pct_of_total.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
}
cards={
<ResponsiveTableCardList>
{data.map((r) => (
<ResponsiveTableCard key={r.role}>
<span className="font-mono text-sm">{r.role}</span>
<div className="mt-2 divide-y">
<ResponsiveTableCardRow label="Cost">
${r.cost_usd.toFixed(4)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Cache hit">
{(r.cache_hit_rate * 100).toFixed(1)}%
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="% of total">
{r.pct_of_total.toFixed(1)}%
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
)}
</CardContent>
</Card>
@@ -696,21 +728,41 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) {
</p>
</div>
{data.by_role.length > 0 && (
<table className="w-full text-xs">
<tbody>
{data.by_role.map((r) => (
<tr key={r.role} className="border-t">
<td className="py-1 font-mono">{r.role}</td>
<td className="py-1 text-right text-muted-foreground">
{r.unproductive}/{r.spawns}
</td>
<td className="py-1 text-right">
{r.unproductive_pct.toFixed(0)}%
</td>
</tr>
))}
</tbody>
</table>
<ResponsiveTable
table={
<table className="w-full text-xs">
<tbody>
{data.by_role.map((r) => (
<tr key={r.role} className="border-t">
<td className="py-1 font-mono">{r.role}</td>
<td className="py-1 text-right text-muted-foreground">
{r.unproductive}/{r.spawns}
</td>
<td className="py-1 text-right">
{r.unproductive_pct.toFixed(0)}%
</td>
</tr>
))}
</tbody>
</table>
}
cards={
<div className="space-y-1.5">
{data.by_role.map((r) => (
<div
key={r.role}
className="flex items-center justify-between border-t pt-1.5 text-xs"
>
<span className="font-mono">{r.role}</span>
<span className="text-muted-foreground">
{r.unproductive}/{r.spawns} (
{r.unproductive_pct.toFixed(0)}%)
</span>
</div>
))}
</div>
}
/>
)}
{data.respawn_strikes.length > 0 && (
<p className="text-xs text-muted-foreground">
@@ -225,40 +225,40 @@ function NotificationsPageContent() {
</div>
</div>
{/* Stats */}
{/* Stats — tighter padding below sm so 3 columns still fit at 375px */}
{!isOffline && data && (
<div className="grid grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold">{data.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Mail className="h-4 w-4" />
Unread
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold text-blue-600">
{data.unread_count}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Bell className="h-4 w-4" />
Pending Ack
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold text-red-600">
{data.pending_ack_count}
</div>
+6 -1
View File
@@ -27,6 +27,7 @@ import { toast } from "sonner";
import { API_URL, WS_URL } from "@/lib/constants";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
import { XCredentialsCard } from "@/components/settings/x-credentials-card";
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
@@ -54,7 +55,8 @@ export default function SettingsPage() {
{/* Cards grid — two columns on large screens. Order (row,col):
User Info (1,1) · Appearance (1,2) · Data & Refresh (2,1) ·
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2). */}
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2) ·
X Credentials (4,1). */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* User Info */}
<Card>
@@ -245,6 +247,9 @@ export default function SettingsPage() {
</p>
</CardContent>
</Card>
{/* X (Twitter) Credentials (4,1) — write-only, panel-tunable */}
<XCredentialsCard />
</div>
{/* Feature Flags — master switches for optional subsystems (full width;
@@ -239,7 +239,7 @@ export function SecretaryTab() {
return (
<div className="grid gap-6 lg:grid-cols-3">
{/* Chat panel */}
<Card className="flex min-h-[60vh] flex-col lg:col-span-2">
<Card className="flex min-h-[60dvh] flex-col lg:col-span-2">
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
<CardTitle>Chat</CardTitle>
{sessionId && (
@@ -52,7 +52,13 @@ export function MessageComposer({
};
return (
<form onSubmit={handleSubmit} className="border-t p-4">
// paddingBottom includes the safe-area inset so the composer clears the
// home indicator on notched phones instead of sitting flush under it.
<form
onSubmit={handleSubmit}
className="border-t p-4"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<Textarea
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { RoadmapCycle } from "@/lib/api/roadmap";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { listCycles, approveItem, rejectItem } = vi.hoisted(() => ({
listCycles: vi.fn(
async () =>
[
{
task_id: "cycle-1",
title: "Roadmap exploration cycle",
status: "pending",
goal: "Close onboarding friction",
items: [
{
id: "item-0",
title: "Streamline signup",
description: "Cut the signup form from 8 fields to 3",
acceptance_criteria: ["signup takes < 30s"],
project_slug: "backend-svc",
team: "backend",
priority: 2,
rationale: "signup drop-off is the top funnel leak",
status: "proposed",
},
{
id: "item-1",
title: "Simplify pricing page",
description: "Remove the three-tier confusion",
acceptance_criteria: ["one clear CTA"],
project_slug: "frontend-app",
team: "frontend",
priority: 3,
rationale: "pricing page bounce rate is high",
status: "proposed",
},
],
},
] as RoadmapCycle[],
),
// Deferred so the test can freeze the approve mid-flight.
approveItem: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
rejectItem: vi.fn(async () => ({})),
}));
vi.mock("@/lib/api", () => ({
roadmapApi: { listCycles, approveItem, rejectItem },
}));
import { RoadmapReviewQueue } from "../roadmap-review-queue";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("RoadmapReviewQueue", () => {
beforeEach(() => {
listCycles.mockClear();
approveItem.mockClear();
rejectItem.mockClear();
resolveApproveRef.current = null;
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders the cycle goal and both item drafts", async () => {
render(withQueryClient(<RoadmapReviewQueue />));
expect(await screen.findByText("Close onboarding friction")).toBeInTheDocument();
expect(screen.getByText("Streamline signup")).toBeInTheDocument();
expect(screen.getByText("Simplify pricing page")).toBeInTheDocument();
});
it("disables only the item being approved, not every row's Approve", async () => {
render(withQueryClient(<RoadmapReviewQueue />));
const approveButtons = await screen.findAllByRole("button", {
name: /Approve/,
});
expect(approveButtons).toHaveLength(2);
expect(approveButtons[0]).not.toBeDisabled();
expect(approveButtons[1]).not.toBeDisabled();
fireEvent.click(approveButtons[0]);
await waitFor(() =>
expect(approveItem).toHaveBeenCalledWith("cycle-1", "item-0"),
);
await waitFor(() => expect(approveButtons[0]).toBeDisabled());
expect(approveButtons[1]).not.toBeDisabled();
resolveApproveRef.current?.({
status: "approved",
item_id: "item-0",
materialized_task_id: "t-1",
detail: "materialized into the backlog",
});
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
});
it("rejects an item with a reason", async () => {
render(withQueryClient(<RoadmapReviewQueue />));
const rejectButtons = await screen.findAllByRole("button", {
name: "Reject",
});
fireEvent.click(rejectButtons[1]);
const reasonBox = await screen.findByLabelText("Reason");
fireEvent.change(reasonBox, { target: { value: "not a priority" } });
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await waitFor(() =>
expect(rejectItem).toHaveBeenCalledWith("cycle-1", "item-1", "not a priority"),
);
});
it("renders nothing when there is no authored cycle", async () => {
listCycles.mockResolvedValueOnce([]);
const { container } = render(withQueryClient(<RoadmapReviewQueue />));
await waitFor(() => expect(listCycles).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { XPost } from "@/lib/api/x";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { listPosts, approve, reject } = vi.hoisted(() => ({
listPosts: vi.fn(
async () =>
[
{
task_id: "x-1",
source: "x_post",
title: "X post: release v0.17.0",
status: "pending",
body: "RoboCo v0.17.0 just shipped!",
char_count: 28,
release_version: "0.17.0",
},
{
task_id: "x-2",
source: "x_reply",
title: "X reply: mention m1",
status: "pending",
body: "Thanks for the shoutout!",
char_count: 24,
mention: { id: "m1", author_id: "a1", text: "great work @roboco" },
},
] as XPost[],
),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
}));
vi.mock("@/lib/api", () => ({ xApi: { listPosts, approve, reject } }));
import { XPostQueue } from "../x-post-queue";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("XPostQueue", () => {
beforeEach(() => {
listPosts.mockClear();
approve.mockClear();
reject.mockClear();
resolveApproveRef.current = null;
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders both a release post and a mention reply draft", async () => {
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Release post")).toBeInTheDocument();
expect(await screen.findByText("Mention reply")).toBeInTheDocument();
expect(screen.getByDisplayValue("RoboCo v0.17.0 just shipped!")).toBeInTheDocument();
});
it("disables only the row being approved, not every row's Approve", async () => {
render(withQueryClient(<XPostQueue />));
const approveButtons = await screen.findAllByRole("button", {
name: /Approve/,
});
expect(approveButtons).toHaveLength(2);
expect(approveButtons[0]).not.toBeDisabled();
expect(approveButtons[1]).not.toBeDisabled();
fireEvent.click(approveButtons[0]);
await waitFor(() =>
expect(approve).toHaveBeenCalledWith("x-1", "RoboCo v0.17.0 just shipped!"),
);
await waitFor(() => expect(approveButtons[0]).toBeDisabled());
expect(approveButtons[1]).not.toBeDisabled();
resolveApproveRef.current?.({ status: "posted", tweet_id: "1", detail: "ok" });
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
});
it("disables Approve when the edited body exceeds 280 characters", async () => {
render(withQueryClient(<XPostQueue />));
const textarea = await screen.findByDisplayValue(
"RoboCo v0.17.0 just shipped!",
);
fireEvent.change(textarea, { target: { value: "x".repeat(281) } });
const approveButtons = await screen.findAllByRole("button", {
name: /Approve/,
});
expect(approveButtons[0]).toBeDisabled();
expect(screen.getByText("281/280")).toBeInTheDocument();
});
it("rejects a draft with a reason", async () => {
render(withQueryClient(<XPostQueue />));
const rejectButtons = await screen.findAllByRole("button", {
name: "Reject",
});
fireEvent.click(rejectButtons[1]);
const reasonBox = await screen.findByLabelText("Reason");
fireEvent.change(reasonBox, { target: { value: "not relevant" } });
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await waitFor(() =>
expect(reject).toHaveBeenCalledWith("x-2", "not relevant"),
);
});
it("renders nothing when the queue is empty", async () => {
listPosts.mockResolvedValueOnce([]);
const { container } = render(withQueryClient(<XPostQueue />));
await waitFor(() => expect(listPosts).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
@@ -213,9 +213,9 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
const renderRow = (task: Task, kind: "start" | "approve") => (
<div
key={task.id}
className="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
className="flex flex-col gap-3 border rounded-lg p-4 transition-colors hover:bg-muted/50 sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
{getPriorityBadge(task.priority)}
<Badge variant="outline">{task.team}</Badge>
@@ -233,7 +233,9 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
{/* Action cluster — stacks below the task text on narrow instead of
clipping against it (mirrors the dialog footer's flex-col sm:flex-row). */}
<div className="flex flex-wrap items-center gap-2 sm:ml-4 sm:shrink-0">
<Link href={`/tasks/${task.id}`} prefetch={false}>
<Button variant="ghost" size="sm">
<FileText className="h-4 w-4" />
@@ -16,6 +16,8 @@ import { CeoApprovalQueue } from "./ceo-approval-queue";
import { PrReviewQueue } from "./pr-review-queue";
import { ReleaseProposalCard } from "./release-proposal-card";
import { PlaybookReviewQueue } from "./playbook-review-queue";
import { XPostQueue } from "./x-post-queue";
import { RoadmapReviewQueue } from "./roadmap-review-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
@@ -60,9 +62,13 @@ export function CommandCenter() {
};
return (
<div className="space-y-6">
// flex-col + explicit `order` (reset via md:order-none): below md the CEO
// decision queues and activity move above the fold; at md+ every item
// shares order:0 and falls back to plain source order (unchanged desktop
// layout).
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="order-1 flex items-center justify-between md:order-none">
<div>
<h1 className="text-3xl font-bold tracking-tight">
RoboCo Command Center
@@ -86,14 +92,56 @@ export function CommandCenter() {
{/* Error indicator */}
{hasError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<div className="order-2 flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive md:order-none">
<AlertCircle className="h-4 w-4 shrink-0" />
Some data failed to load. Click Refresh to try again.
</div>
)}
{/* CEO Approval Queue + Strategy Signals - side-by-side on lg+. Ordered
first on mobile — the CEO's decisions shouldn't be below the fold. */}
<div className="order-3 grid grid-cols-1 gap-6 md:order-none lg:grid-cols-2">
<CeoApprovalQueue />
<StrategySignalsPanel />
</div>
{/* External-PR review decision queue (hidden when empty) */}
<div className="order-4 md:order-none">
<PrReviewQueue />
</div>
{/* Gated release proposal (hidden when none open) */}
<div className="order-4 md:order-none">
<ReleaseProposalCard />
</div>
{/* Playbook review queue (hidden when no drafts) */}
<div className="order-4 md:order-none">
<PlaybookReviewQueue />
</div>
{/* X post/reply queue (hidden when no drafts) */}
<div className="order-4 md:order-none">
<XPostQueue />
</div>
{/* Board roadmap queue (hidden when no cycle authored) */}
<div className="order-4 md:order-none">
<RoadmapReviewQueue />
</div>
{/* Blockers and Activity Row — activity brought up near the top on
mobile too, ahead of the Team Health / Quick Actions filler. */}
<div className="order-5 grid grid-cols-1 gap-6 md:order-none lg:grid-cols-2">
<ActiveBlockersPanel tasks={tasks} isLoading={loadingTasks} />
<RecentActivityFeed
activities={activity as Activity[] | undefined}
isLoading={loadingActivity}
/>
</div>
{/* Team Health */}
<section>
<section className="order-6 md:order-none">
<h2 className="text-lg font-semibold mb-4">Team Health</h2>
<TeamHealthCards
teams={overview?.health_status}
@@ -101,31 +149,14 @@ export function CommandCenter() {
/>
</section>
{/* Quick Actions — placed immediately after Team Health so it is
visible without scrolling on a 900px-tall viewport, before the
data-heavy grid rows below. */}
<section>
{/* Quick Actions */}
<section className="order-7 md:order-none">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<QuickActionsBar />
</section>
{/* CEO Approval Queue + Strategy Signals - side-by-side on lg+ */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CeoApprovalQueue />
<StrategySignalsPanel />
</div>
{/* External-PR review decision queue (hidden when empty) */}
<PrReviewQueue />
{/* Gated release proposal (hidden when none open) */}
<ReleaseProposalCard />
{/* Playbook review queue (hidden when no drafts) */}
<PlaybookReviewQueue />
{/* Metrics, Alerts, Usage, and Performance Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6">
<div className="order-8 grid grid-cols-1 gap-6 md:order-none lg:grid-cols-2 xl:grid-cols-4">
<KeyMetricsPanel
metrics={overview?.key_metrics}
isLoading={loadingOverview}
@@ -134,15 +165,6 @@ export function CommandCenter() {
<UsageOverviewPanel />
<ScorecardOverviewPanel />
</div>
{/* Blockers and Activity Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<ActiveBlockersPanel tasks={tasks} isLoading={loadingTasks} />
<RecentActivityFeed
activities={activity as Activity[] | undefined}
isLoading={loadingActivity}
/>
</div>
</div>
);
}
+1
View File
@@ -12,6 +12,7 @@ export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue";
export { ReleaseProposalCard } from "./release-proposal-card";
export { PlaybookReviewQueue } from "./playbook-review-queue";
export { RoadmapReviewQueue } from "./roadmap-review-queue";
export { StrategySignalsPanel } from "./strategy-signals-panel";
export { UsageOverviewPanel } from "./usage-overview-panel";
export { ScorecardOverviewPanel } from "./scorecard-overview-panel";
@@ -119,7 +119,7 @@ export function PlaybookReviewQueue({ className }: { className?: string }) {
<pre className="mt-2 max-h-40 overflow-auto rounded bg-muted p-2 text-xs whitespace-pre-wrap">
{pb.procedure}
</pre>
<div className="mt-3 flex items-center justify-end gap-2">
<div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
@@ -227,7 +227,7 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
</p>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
@@ -0,0 +1,284 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { roadmapApi } from "@/lib/api";
import type { RoadmapCycle, RoadmapItem } from "@/lib/api/roadmap";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CheckCircle2, Map, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
interface RejectTarget {
taskId: string;
item: RoadmapItem;
}
function itemStatusBadge(item: RoadmapItem) {
if (item.status === "approved") {
return (
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
);
}
if (item.status === "rejected") {
return <Badge variant="outline">Rejected</Badge>;
}
return null;
}
// One roadmap item row: details + per-item approve/reject (proposed only).
function RoadmapItemRow({
taskId,
item,
onApprove,
onReject,
approving,
}: {
taskId: string;
item: RoadmapItem;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approving: boolean;
}) {
const isProposed = item.status === "proposed";
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-medium">{item.title}</span>
<Badge variant="outline">{item.team}</Badge>
<Badge variant="outline">{item.project_slug}</Badge>
<Badge variant="secondary">P{item.priority}</Badge>
{itemStatusBadge(item)}
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
<p className="mt-1 text-sm text-muted-foreground">
<span className="font-semibold">Why:</span> {item.rationale}
</p>
{item.acceptance_criteria.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-sm text-muted-foreground">
{item.acceptance_criteria.map((ac) => (
<li key={ac}>{ac}</li>
))}
</ul>
)}
{item.status === "rejected" && item.reject_reason && (
<p className="mt-2 text-sm text-destructive">
Rejected: {item.reject_reason}
</p>
)}
{isProposed && (
<div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject({ taskId, item })}
>
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(taskId, item.id)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve
</Button>
</div>
)}
</div>
);
}
function RoadmapCycleCard({
cycle,
onApprove,
onReject,
approvingItemId,
}: {
cycle: RoadmapCycle;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approvingItemId: string | null;
}) {
const pending = cycle.items.filter((i) => i.status === "proposed").length;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Map className="h-5 w-5" />
Roadmap Cycle
<Badge variant="secondary">{pending} pending</Badge>
</CardTitle>
<CardDescription>{cycle.goal}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{cycle.items.map((item) => (
<RoadmapItemRow
key={item.id}
taskId={cycle.task_id}
item={item}
onApprove={onApprove}
onReject={onReject}
approving={approvingItemId === item.id}
/>
))}
</CardContent>
</Card>
);
}
// CEO queue for the Product Owner's held roadmap cycles. Hidden when no
// cycle has been authored yet (mirrors the playbook + X post queues).
export function RoadmapReviewQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
const [reason, setReason] = useState("");
const [approvingItemId, setApprovingItemId] = useState<string | null>(null);
const { data: cycles, isLoading } = useQuery({
queryKey: ["roadmap", "cycles"],
queryFn: () => roadmapApi.listCycles(),
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["roadmap", "cycles"] });
const approveMutation = useMutation({
mutationFn: ({ taskId, itemId }: { taskId: string; itemId: string }) =>
roadmapApi.approveItem(taskId, itemId),
onSuccess: (result) => {
invalidate();
if (result.status === "approved" || result.status === "already_approved") {
toast.success("Item approved — added to the backlog");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingItemId(null),
});
const rejectMutation = useMutation({
mutationFn: ({
taskId,
itemId,
reason,
}: {
taskId: string;
itemId: string;
reason: string;
}) => roadmapApi.rejectItem(taskId, itemId, reason),
onSuccess: () => {
invalidate();
toast.success("Item rejected");
closeReject();
},
onError: (e) =>
toast.error(`Reject failed: ${e instanceof Error ? e.message : "error"}`),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for rejecting");
return;
}
rejectMutation.mutate({
taskId: rejecting.taskId,
itemId: rejecting.item.id,
reason: reason.trim(),
});
};
const handleApprove = (taskId: string, itemId: string) => {
setApprovingItemId(itemId);
approveMutation.mutate({ taskId, itemId });
};
if (isLoading || !cycles || cycles.length === 0) return null;
return (
<>
<div className={`space-y-4 ${className ?? ""}`}>
{cycles.map((cycle) => (
<RoadmapCycleCard
key={cycle.task_id}
cycle={cycle}
onApprove={handleApprove}
onReject={setRejecting}
approvingItemId={approvingItemId}
/>
))}
</div>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject roadmap item</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="roadmap-reject-reason">Reason</Label>
<Textarea
id="roadmap-reject-reason"
placeholder="e.g. not a priority this quarter; overlaps an existing task..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Rejecting..." : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,275 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { xApi } from "@/lib/api";
import type { XPost, XPostExecuteResult } from "@/lib/api/x";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { AtSign, CheckCircle2, Rocket, XCircle } from "lucide-react";
import { toast } from "sonner";
const MAX_TWEET_CHARS = 280;
const _MIN_REASON_CHARS = 4;
function sourceMeta(source: XPost["source"]) {
return source === "x_post"
? { label: "Release post", icon: Rocket }
: { label: "Mention reply", icon: AtSign };
}
function describeExecuteResult(result: XPostExecuteResult): string {
if (result.status === "posted") return "Posted to X.";
if (result.status === "already_posted") return "Already posted — no-op.";
if (result.status === "already_in_progress")
return "A post is already in progress for this draft.";
if (result.status === "no_credentials")
return "No X credentials configured — set them below first.";
return `${result.status}: ${result.detail}`;
}
// One row of the queue: an editable draft body + char counter + approve/reject.
function XPostRow({
post,
onApprove,
onReject,
approving,
}: {
post: XPost;
onApprove: (taskId: string, body: string) => void;
onReject: (post: XPost) => void;
approving: boolean;
}) {
// `edited` holds the user's in-progress textarea input; null means "show
// the server value". Deriving the displayed body avoids syncing query
// state into local state with an effect (mirrors TranscriptRetentionCard).
const [edited, setEdited] = useState<string | null>(null);
const body = edited ?? post.body;
const meta = sourceMeta(post.source);
const overLimit = body.length > MAX_TWEET_CHARS;
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-2 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
{post.release_version && (
<Badge variant="outline">v{post.release_version}</Badge>
)}
{post.mention && (
<Badge variant="secondary" className="max-w-56 truncate">
re: {post.mention.text}
</Badge>
)}
</div>
<Textarea
value={body}
onChange={(e) => setEdited(e.target.value)}
rows={3}
className={overLimit ? "border-destructive" : undefined}
/>
<p
className={`mt-1 text-right text-xs ${
overLimit ? "text-destructive" : "text-muted-foreground"
}`}
>
{body.length}/{MAX_TWEET_CHARS}
</p>
<div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject(post)}
>
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit || body.trim().length === 0}
onClick={() => onApprove(post.task_id, body)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
</div>
</div>
);
}
// CEO queue for held X drafts (release posts + mention replies). Hidden when
// empty (mirrors the release-proposal + playbook-review queues).
export function XPostQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<XPost | null>(null);
const [reason, setReason] = useState("");
const [approvingId, setApprovingId] = useState<string | null>(null);
const { data: posts, isLoading } = useQuery({
queryKey: ["x", "posts"],
queryFn: () => xApi.listPosts(),
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["x", "posts"] });
const approveMutation = useMutation({
mutationFn: ({ taskId, body }: { taskId: string; body: string }) =>
xApi.approve(taskId, body),
onSuccess: (result) => {
invalidate();
if (result.status === "posted") {
toast.success(describeExecuteResult(result));
} else {
toast.warning(describeExecuteResult(result));
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingId(null),
});
const rejectMutation = useMutation({
mutationFn: ({ taskId, reason }: { taskId: string; reason: string }) =>
xApi.reject(taskId, reason),
onSuccess: () => {
invalidate();
toast.success("Draft rejected");
closeReject();
},
onError: (e) =>
toast.error(`Reject failed: ${e instanceof Error ? e.message : "error"}`),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for rejecting");
return;
}
rejectMutation.mutate({ taskId: rejecting.task_id, reason: reason.trim() });
};
const handleApprove = (taskId: string, body: string) => {
setApprovingId(taskId);
approveMutation.mutate({ taskId, body });
};
if (isLoading) return null;
if (!posts || posts.length === 0) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
</CardTitle>
<CardDescription>
Drafted release announcements (and mention replies, if enabled) land
here for you to edit, approve, or reject. Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No drafts yet. A post is drafted here when a release publishes set
your keys in Settings X (Twitter) Credentials and enable the X
engine to start.
</p>
</CardContent>
</Card>
);
}
return (
<>
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
<Badge variant="secondary">{posts.length}</Badge>
</CardTitle>
<CardDescription>
Drafted release announcements and mention replies edit, approve
(posts to X), or reject. Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{posts.map((post) => (
<XPostRow
key={post.task_id}
post={post}
onApprove={handleApprove}
onReject={setRejecting}
approving={approvingId === post.task_id}
/>
))}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject draft</DialogTitle>
<DialogDescription>
This cancels the draft it will not be posted. Give a brief
reason (it is recorded).
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="x-reject-reason">Reason</Label>
<Textarea
id="x-reject-reason"
placeholder="e.g. tone doesn't match our voice; not worth a public reply..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Rejecting..." : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+50 -9
View File
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { GitDiffResponse } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FileCode, FileDiff } from "lucide-react";
import { cn } from "@/lib/utils";
import { FileCode, FileDiff, WrapText } from "lucide-react";
interface GitDiffViewerProps {
stagedDiff: GitDiffResponse | undefined;
@@ -18,9 +21,11 @@ interface GitDiffViewerProps {
function DiffContent({
diff,
isLoading,
wrap,
}: {
diff: GitDiffResponse | undefined;
isLoading: boolean;
wrap: boolean;
}) {
if (isLoading) {
return (
@@ -45,8 +50,15 @@ function DiffContent({
const lines = diff.diff.split("\n");
return (
// overflow-x-auto is the horizontal-scroll affordance for un-wrapped long
// lines on a phone; smaller mobile font, back to the desktop size at sm+.
<ScrollArea className="h-96">
<pre className="p-4 text-xs font-mono leading-relaxed">
<pre
className={cn(
"p-4 font-mono text-[11px] leading-relaxed sm:text-xs",
wrap ? "whitespace-pre-wrap break-all" : "overflow-x-auto",
)}
>
{lines.map((line, i) => {
let className = "";
if (line.startsWith("+") && !line.startsWith("+++")) {
@@ -60,7 +72,14 @@ function DiffContent({
}
return (
<div key={i} className={`px-2 -mx-2 whitespace-pre ${className}`}>
<div
key={i}
className={cn(
"px-2 -mx-2",
wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre",
className,
)}
>
{line || " "}
</div>
);
@@ -78,14 +97,28 @@ export function GitDiffViewer({
}: GitDiffViewerProps) {
const stagedCount = stagedDiff?.files_changed || 0;
const unstagedCount = unstagedDiff?.files_changed || 0;
const [wrap, setWrap] = useState(false);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileCode className="h-4 w-4" />
Changes
</CardTitle>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileCode className="h-4 w-4" />
Changes
</CardTitle>
<Button
variant={wrap ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={wrap}
onClick={() => setWrap((w) => !w)}
title="Toggle line wrap"
>
<WrapText className="h-3.5 w-3.5 mr-1" />
Wrap
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Tabs defaultValue="unstaged">
@@ -111,11 +144,19 @@ export function GitDiffViewer({
</div>
<TabsContent value="unstaged" className="m-0">
<DiffContent diff={unstagedDiff} isLoading={isLoadingUnstaged} />
<DiffContent
diff={unstagedDiff}
isLoading={isLoadingUnstaged}
wrap={wrap}
/>
</TabsContent>
<TabsContent value="staged" className="m-0">
<DiffContent diff={stagedDiff} isLoading={isLoadingStaged} />
<DiffContent
diff={stagedDiff}
isLoading={isLoadingStaged}
wrap={wrap}
/>
</TabsContent>
</Tabs>
</CardContent>
@@ -353,7 +353,7 @@ function KnowledgeBaseBrowserContent() {
onChange={handleSearchChange}
isLoading={searchLoading}
/>
<ScrollArea className="h-[calc(100vh-380px)]">
<ScrollArea className="h-[calc(100dvh-380px)]">
<KBResultList
response={searchResults}
isLoading={searchLoading}
@@ -378,7 +378,7 @@ function KnowledgeBaseBrowserContent() {
onSubmit={handleRAGQuery}
isLoading={ragMutation.isPending}
/>
<ScrollArea className="h-[calc(100vh-450px)]">
<ScrollArea className="h-[calc(100dvh-450px)]">
<RAGAnswerDisplay
response={ragResponse}
isLoading={ragMutation.isPending}
@@ -410,7 +410,7 @@ function KnowledgeBaseBrowserContent() {
{/* Main content */}
<div className="lg:col-span-3">
<ScrollArea className="h-[calc(100vh-320px)]">
<ScrollArea className="h-[calc(100dvh-320px)]">
<KBCategoryView category={selectedCategory} />
</ScrollArea>
</div>
@@ -104,7 +104,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
// Empty state
if (messages.length === 0 && !isLoading) {
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
<div className="flex flex-col h-[calc(100dvh-280px)]">
{/* Empty state */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
@@ -159,7 +159,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
}
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
<div className="flex flex-col h-[calc(100dvh-280px)]">
{/* Header with New Chat */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, ListTodo, Kanban, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
const BOTTOM_NAV_ITEMS = [
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
{ title: "Tasks", href: "/tasks", icon: ListTodo },
{ title: "Kanban", href: "/kanban", icon: Kanban },
{ title: "Chat", href: "/prompter", icon: Sparkles },
];
/**
* Persistent one-thumb-reach bottom nav for the 4 most-used destinations,
* alongside the full-nav drawer (MobileSidebar) the drawer covers every
* route, this covers the common loop without opening it. `md:hidden` mirrors
* the sidebar's own breakpoint so exactly one nav surface is ever visible.
*/
export function BottomTabBar() {
const pathname = usePathname();
return (
<nav
aria-label="Primary"
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden"
>
{BOTTOM_NAV_ITEMS.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
prefetch={false}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
<item.icon className="h-5 w-5" />
{item.title}
</Link>
);
})}
</nav>
);
}
@@ -11,6 +11,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { AgentUsageRow } from "@/types";
interface AgentUsageChartProps {
@@ -24,9 +25,12 @@ function fmtK(n: number): string {
}
export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
const isMobile = useIsMobile();
// Fewer bars on a phone — 10 labels at ~30deg rotation still overlap below
// ~400px, so cap the label density instead of shrinking text further.
const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens)
.slice(0, 10)
.slice(0, isMobile ? 6 : 10)
.map((row) => ({
name: row.agent_slug,
Tokens: row.total_tokens,
@@ -49,9 +53,10 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 10 }}
angle={-30}
tick={{ fontSize: isMobile ? 9 : 10 }}
angle={isMobile ? -45 : -30}
textAnchor="end"
interval={0}
axisLine={false}
tickLine={false}
/>
+59 -23
View File
@@ -12,6 +12,12 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import {
useCycleTime,
useBottlenecks,
@@ -185,29 +191,59 @@ function ReworkCard() {
))}
</div>
{(data?.by_agent ?? []).length > 0 && (
<table className="w-full text-xs">
<thead className="text-muted-foreground">
<tr className="text-left">
<th className="py-1 font-medium">Agent</th>
<th className="py-1 font-medium text-right">Rate</th>
<th className="py-1 font-medium text-right">QA fails</th>
<th className="py-1 font-medium text-right">PR fails</th>
</tr>
</thead>
<tbody>
{(data?.by_agent ?? []).slice(0, 8).map((a) => (
<tr
key={a.agent_slug}
className="border-t border-border/50"
>
<td className="py-1">{a.agent_slug}</td>
<td className="py-1 text-right">{pct(a.rate)}</td>
<td className="py-1 text-right">{a.qa_fails}</td>
<td className="py-1 text-right">{a.pr_fails}</td>
</tr>
))}
</tbody>
</table>
<ResponsiveTable
table={
<table className="w-full text-xs">
<thead className="text-muted-foreground">
<tr className="text-left">
<th className="py-1 font-medium">Agent</th>
<th className="py-1 font-medium text-right">Rate</th>
<th className="py-1 font-medium text-right">
QA fails
</th>
<th className="py-1 font-medium text-right">
PR fails
</th>
</tr>
</thead>
<tbody>
{(data?.by_agent ?? []).slice(0, 8).map((a) => (
<tr
key={a.agent_slug}
className="border-t border-border/50"
>
<td className="py-1">{a.agent_slug}</td>
<td className="py-1 text-right">{pct(a.rate)}</td>
<td className="py-1 text-right">{a.qa_fails}</td>
<td className="py-1 text-right">{a.pr_fails}</td>
</tr>
))}
</tbody>
</table>
}
cards={
<ResponsiveTableCardList>
{(data?.by_agent ?? []).slice(0, 8).map((a) => (
<ResponsiveTableCard key={a.agent_slug}>
<span className="text-sm font-medium">
{a.agent_slug}
</span>
<div className="mt-2 divide-y">
<ResponsiveTableCardRow label="Rate">
{pct(a.rate)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="QA fails">
{a.qa_fails}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="PR fails">
{a.pr_fails}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
)}
</>
)}
@@ -10,6 +10,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { ModelUsageSlice } from "@/types";
// Design-system chart tokens — resolves to theme-aware palette
@@ -27,6 +28,7 @@ interface ModelUsageDonutProps {
}
export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
const isMobile = useIsMobile();
const chartData = (data ?? []).map((s) => ({
name: s.model,
value: s.total_tokens,
@@ -49,8 +51,8 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
data={chartData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={80}
innerRadius={isMobile ? 44 : 52}
outerRadius={isMobile ? 68 : 80}
dataKey="value"
paddingAngle={3}
>
@@ -69,7 +71,7 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: isMobile ? 9 : 11 }} />
</PieChart>
</ResponsiveContainer>
)}
+109 -56
View File
@@ -9,7 +9,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { ChevronUp, ChevronDown } from "lucide-react";
@@ -120,63 +127,109 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
</div>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{COLUMNS.map((col) => (
<TableHead
key={col.key}
className="cursor-pointer select-none text-xs whitespace-nowrap"
onClick={() => toggleSort(col.key)}
>
{col.label}
<SortIcon col={col.key} />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 ? (
<TableRow>
<TableCell
colSpan={COLUMNS.length}
className="text-center text-muted-foreground text-sm py-8"
>
No sessions recorded yet
</TableCell>
</TableRow>
) : (
visible.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-xs font-medium">
{s.agent_slug}
</TableCell>
<TableCell className="text-xs">{s.model}</TableCell>
<TableCell className="text-xs">
{formatTime(s.started_at)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.total_tokens)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.tokens_input)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.tokens_output)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.tokens_cache)}
</TableCell>
<TableCell className="text-xs">
${s.cost.toFixed(4)}
</TableCell>
<ResponsiveTable
table={
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{COLUMNS.map((col) => (
<TableHead
key={col.key}
className="cursor-pointer select-none text-xs whitespace-nowrap"
onClick={() => toggleSort(col.key)}
>
{col.label}
<SortIcon col={col.key} />
</TableHead>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{visible.length === 0 ? (
<TableRow>
<TableCell
colSpan={COLUMNS.length}
className="text-center text-muted-foreground text-sm py-8"
>
No sessions recorded yet
</TableCell>
</TableRow>
) : (
visible.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-xs font-medium">
{s.agent_slug}
</TableCell>
<TableCell className="text-xs">{s.model}</TableCell>
<TableCell className="text-xs">
{formatTime(s.started_at)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.total_tokens)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.tokens_input)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.tokens_output)}
</TableCell>
<TableCell className="text-xs">
{fmtK(s.tokens_cache)}
</TableCell>
<TableCell className="text-xs">
${s.cost.toFixed(4)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
}
cards={
visible.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No sessions recorded yet
</p>
) : (
<ResponsiveTableCardList>
{visible.map((s) => (
<ResponsiveTableCard key={s.id}>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{s.agent_slug}
</span>
<Badge variant="outline" className="shrink-0 text-xs">
{s.model}
</Badge>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Started">
{formatTime(s.started_at)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Total">
{fmtK(s.total_tokens)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Input">
{fmtK(s.tokens_input)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Output">
{fmtK(s.tokens_output)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Cache">
{fmtK(s.tokens_cache)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Cost">
${s.cost.toFixed(4)}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
)
}
/>
{/* Pagination */}
<div className="flex items-center justify-between mt-3 pt-3 border-t text-sm">
@@ -11,6 +11,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { TeamUsageRow } from "@/types";
interface TeamUsageChartProps {
@@ -24,6 +25,7 @@ function fmtK(n: number): string {
}
export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
const isMobile = useIsMobile();
const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens)
.map((row) => ({
@@ -43,12 +45,20 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 8 }}
margin={{
top: 4,
right: 8,
left: 0,
bottom: isMobile ? 24 : 8,
}}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
tick={{ fontSize: isMobile ? 9 : 11 }}
angle={isMobile ? -45 : 0}
textAnchor={isMobile ? "end" : "middle"}
interval={0}
axisLine={false}
tickLine={false}
/>
@@ -12,6 +12,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { UsageTimePoint } from "@/types";
interface UsageTimeSeriesChartProps {
@@ -40,6 +41,7 @@ export function UsageTimeSeriesChart({
data,
isLoading,
}: UsageTimeSeriesChartProps) {
const isMobile = useIsMobile();
const chartData = (data ?? []).map((p) => ({
hour: formatBucket(p.bucket),
Input: p.tokens_input,
@@ -89,8 +91,8 @@ export function UsageTimeSeriesChart({
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="hour"
tick={{ fontSize: 10 }}
interval={3}
tick={{ fontSize: isMobile ? 9 : 10 }}
interval={isMobile ? 5 : 3}
axisLine={false}
tickLine={false}
/>
@@ -108,7 +110,7 @@ export function UsageTimeSeriesChart({
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: isMobile ? 10 : 12 }} />
<Area
type="monotone"
dataKey="Input"
+83 -35
View File
@@ -11,6 +11,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { Boxes, Pencil } from "lucide-react";
import type { ProductSummary } from "@/types";
@@ -46,20 +52,63 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
return (
<>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Cells Mapped</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<ResponsiveTable
table={
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Cells Mapped</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>
<div>
<Button
onClick={() => setEditingProductId(product.id)}
variant="link"
className="h-auto p-0 font-medium text-foreground"
>
{product.name}
</Button>
<p className="text-xs text-muted-foreground font-mono">
{product.slug}
</p>
</div>
</TableCell>
<TableCell>
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProductId(product.id)}
title="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
}
cards={
<ResponsiveTableCardList>
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>
<div>
<ResponsiveTableCard key={product.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Button
onClick={() => setEditingProductId(product.id)}
variant="link"
@@ -71,29 +120,28 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
{product.slug}
</p>
</div>
</TableCell>
<TableCell>
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProductId(product.id)}
title="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => setEditingProductId(product.id)}
title="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cells Mapped">
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</TableBody>
</Table>
</div>
</ResponsiveTableCardList>
}
/>
{/* Edit Product Dialog */}
{editingProductId && (
@@ -82,6 +82,13 @@ function EditProjectForm({
const [depUpdatePaths, setDepUpdatePaths] = useState(
(project.dep_update_paths || []).join(", "),
);
const sandboxServices = project.sandbox_services || [];
const [sandboxPostgres, setSandboxPostgres] = useState(
sandboxServices.includes("postgres"),
);
const [sandboxRedis, setSandboxRedis] = useState(
sandboxServices.includes("redis"),
);
// Token handling
const [newToken, setNewToken] = useState("");
@@ -120,6 +127,10 @@ function EditProjectForm({
.map((p) => p.trim())
.filter(Boolean)
: undefined,
sandbox_services: [
...(sandboxPostgres ? ["postgres"] : []),
...(sandboxRedis ? ["redis"] : []),
],
};
// Handle token update
@@ -430,6 +441,37 @@ function EditProjectForm({
uv.lock / pnpm-lock.yaml.
</p>
</div>
<div className="grid gap-2">
<Label>Sandbox Services</Label>
<div className="flex items-center justify-between">
<Label
htmlFor="sandbox_postgres"
className="text-sm font-normal"
>
PostgreSQL
</Label>
<Switch
id="sandbox_postgres"
checked={sandboxPostgres}
onCheckedChange={setSandboxPostgres}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sandbox_redis" className="text-sm font-normal">
Redis
</Label>
<Switch
id="sandbox_redis"
checked={sandboxRedis}
onCheckedChange={setSandboxRedis}
/>
</div>
<p className="text-xs text-muted-foreground">
Provision a throwaway sandbox DB/Redis per agent spawn for
this project instead of the production credentials.
</p>
</div>
</>
)}
</div>
+125 -40
View File
@@ -11,6 +11,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink, Pencil, GitBranch, Key, KeyRound } from "lucide-react";
import type { ProjectSummary, Team } from "@/types";
@@ -97,22 +103,96 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
return (
<>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Cell</TableHead>
<TableHead>Token</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<ResponsiveTable
table={
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Cell</TableHead>
<TableHead>Token</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell>
<div>
<Button
onClick={() => setEditingProjectId(project.id)}
variant="link"
className="h-auto p-0 font-medium text-foreground"
>
{project.name}
</Button>
<p className="text-xs text-muted-foreground font-mono">
{project.slug}
</p>
</div>
</TableCell>
<TableCell>
<Badge className={teamColors[project.assigned_cell]}>
{teamLabels[project.assigned_cell]}
</Badge>
</TableCell>
<TableCell>
{getTokenBadge(project.has_git_token)}
</TableCell>
<TableCell>
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProjectId(project.id)}
title="Edit project"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
asChild
title="View repository"
>
<a
href={getExternalUrl(project)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
}
cards={
<ResponsiveTableCardList>
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell>
<div>
<ResponsiveTableCard key={project.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Button
onClick={() => setEditingProjectId(project.id)}
variant="link"
@@ -124,26 +204,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
{project.slug}
</p>
</div>
</TableCell>
<TableCell>
<Badge className={teamColors[project.assigned_cell]}>
{teamLabels[project.assigned_cell]}
</Badge>
</TableCell>
<TableCell>{getTokenBadge(project.has_git_token)}</TableCell>
<TableCell>
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Inactive
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
@@ -167,12 +228,36 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
</a>
</Button>
</div>
</TableCell>
</TableRow>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cell">
<Badge className={teamColors[project.assigned_cell]}>
{teamLabels[project.assigned_cell]}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Token">
{getTokenBadge(project.has_git_token)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status">
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</TableBody>
</Table>
</div>
</ResponsiveTableCardList>
}
/>
{/* Edit Project Dialog */}
{editingProjectId && (
@@ -46,7 +46,12 @@ export function ChatComposer({
};
return (
<div className="flex items-end gap-2 border-t bg-background px-4 py-3">
// pb includes the safe-area inset so the composer clears the home
// indicator on notched phones instead of sitting flush under it.
<div
className="flex items-end gap-2 border-t bg-background px-4 py-3"
style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
>
<Textarea
ref={textareaRef}
value={value}
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const { getCredentialsStatus, setCredentials } = vi.hoisted(() => ({
getCredentialsStatus: vi.fn(async () => ({ has_credentials: false })),
setCredentials: vi.fn(async () => ({ has_credentials: true })),
}));
vi.mock("@/lib/api", () => ({
xApi: { getCredentialsStatus, setCredentials },
}));
import { XCredentialsCard } from "../x-credentials-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("XCredentialsCard", () => {
beforeEach(() => {
getCredentialsStatus.mockClear();
setCredentials.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("shows 'no credentials configured' by default and never renders a secret", async () => {
render(withQueryClient(<XCredentialsCard />));
expect(
await screen.findByText("No credentials configured"),
).toBeInTheDocument();
});
it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<XCredentialsCard />));
await screen.findByText("No credentials configured");
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled();
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: "ak" },
});
expect(saveButton).toBeDisabled(); // still 3 unfilled
fireEvent.change(screen.getByLabelText("API key secret"), {
target: { value: "as" },
});
fireEvent.change(screen.getByLabelText("Access token"), {
target: { value: "at" },
});
fireEvent.change(screen.getByLabelText("Access token secret"), {
target: { value: "ats" },
});
expect(saveButton).not.toBeDisabled();
});
it("saves all 4 secrets and clears the inputs on success", async () => {
render(withQueryClient(<XCredentialsCard />));
await screen.findByText("No credentials configured");
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: "ak" },
});
fireEvent.change(screen.getByLabelText("API key secret"), {
target: { value: "as" },
});
fireEvent.change(screen.getByLabelText("Access token"), {
target: { value: "at" },
});
fireEvent.change(screen.getByLabelText("Access token secret"), {
target: { value: "ats" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setCredentials).toHaveBeenCalledWith({
api_key: "ak",
api_secret: "as",
access_token: "at",
access_token_secret: "ats",
}),
);
await waitFor(() =>
expect(
(screen.getByLabelText("API key") as HTMLInputElement).value,
).toBe(""),
);
});
});
@@ -46,8 +46,16 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
org_memory_enabled:
"Close the learn→reuse loop: distill a lesson at task completion, index journal reflections, and auto-inject similar past lessons + approved playbooks into an agent's briefing on claim.",
sandbox_db_enabled:
"Provision a throwaway Postgres/Redis sibling container per agent spawn (per-project opt-in) instead of injecting RoboCo's own production DB credentials into the agent's gate.",
routing_strict:
"Fail-closed model routing: refuse to silently downgrade an agent to the legacy Anthropic path when its configured provider is disabled (raise instead). Off => graceful degradation with a warning.",
x_engine_enabled:
"Draft release-announcement posts for the X (Twitter) account — every draft is held in a queue for you to edit, approve, or reject; nothing posts automatically, and it stays inert until you set credentials in the X card below.",
x_replies_enabled:
"Also poll X mentions and draft replies (still held for your approval — nothing auto-replies). Off by default: reading mentions needs a paid X API tier, so leave this off if you only want release posts.",
roadmap_engine_enabled:
"Weekly: the Product Owner explores the company's projects and proposes a themed cycle of 3-7 roadmap items — you approve or reject each one individually; approved items land in the backlog and nothing auto-starts.",
};
export function FeatureFlagsCard() {
@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { xApi } from "@/lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { AtSign, Key, KeyRound, Save } from "lucide-react";
import { toast } from "sonner";
const FIELDS: Array<{
key: "api_key" | "api_secret" | "access_token" | "access_token_secret";
label: string;
}> = [
{ key: "api_key", label: "API key" },
{ key: "api_secret", label: "API key secret" },
{ key: "access_token", label: "Access token" },
{ key: "access_token_secret", label: "Access token secret" },
];
// The CEO's one-time (or rotate) entry of the 4 OAuth 1.0a user-context
// secrets from the X developer app. Write-only — the stored values are never
// displayed back, only whether they're set (mirrors the git-token card).
export function XCredentialsCard() {
const queryClient = useQueryClient();
const [values, setValues] = useState({
api_key: "",
api_secret: "",
access_token: "",
access_token_secret: "",
});
const { data: status, isLoading } = useQuery({
queryKey: ["x", "credentials"],
queryFn: () => xApi.getCredentialsStatus(),
});
const saveMutation = useMutation({
mutationFn: () => xApi.setCredentials(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["x", "credentials"] });
setValues({
api_key: "",
api_secret: "",
access_token: "",
access_token_secret: "",
});
toast.success("X credentials saved");
},
onError: (error) => {
toast.error(
`Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
});
const allFilled = FIELDS.every((f) => values[f.key].trim().length > 0);
const noneFilled = FIELDS.every((f) => values[f.key].trim().length === 0);
// A genuine save is either "set all 4" or, when something is already
// stored, "clear all 4". All-empty with nothing stored is a true no-op.
const canSave = allFilled || (noneFilled && !!status?.has_credentials);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AtSign className="h-5 w-5" />X (Twitter) Credentials
</CardTitle>
<CardDescription>
The 4 OAuth 1.0a user-context secrets from your X developer app.
Stored encrypted server-side; agents never see them and this panel
never displays them again once saved.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 rounded-md border p-3">
{status?.has_credentials ? (
<>
<Key className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-600 dark:text-green-400">
Credentials are set
</span>
</>
) : (
<>
<KeyRound className="h-4 w-4 text-amber-500" />
<span className="text-sm text-amber-600 dark:text-amber-400">
{isLoading ? "Checking..." : "No credentials configured"}
</span>
</>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`x-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<Input
id={`x-cred-${field.key}`}
type="password"
value={values[field.key]}
onChange={(e) =>
setValues((prev) => ({ ...prev, [field.key]: e.target.value }))
}
placeholder="••••••••••••"
/>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Set all 4 to save (or rotate); leave all 4 blank and save to clear.
</p>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !canSave}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
</CardContent>
</Card>
);
}
@@ -501,8 +501,10 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
{/* Row 2: copyable task id + status + team + type. The dropdowns are
FIXED width so changing a selected value's label width can never
shift a neighbor; the id is read-only and copies the FULL uuid. */}
<div className="flex items-center gap-2 mt-1.5">
shift a neighbor; the id is read-only and copies the FULL uuid.
flex-wrap: on narrow viewports the id/status/team/type group
wraps to further rows instead of overflowing horizontally. */}
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<span
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border bg-muted/40 px-2.5 font-mono text-sm text-muted-foreground"
title={task.id}
+342 -194
View File
@@ -22,6 +22,13 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
ResponsiveTableCardEmpty,
} from "@/components/ui/responsive-table";
import { TaskStatusBadge } from "./task-status-badge";
import { TaskActions } from "./task-actions";
import { GitStatusBadge } from "./git-status-badge";
@@ -199,6 +206,19 @@ function TaskTableEmpty() {
);
}
function TaskCardSkeletons() {
return (
<ResponsiveTableCardList className="p-3">
{Array.from({ length: 5 }).map((_, i) => (
<ResponsiveTableCard key={i} className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
);
}
interface SortableHeaderProps {
label: string;
field: SortField;
@@ -455,207 +475,335 @@ export function TaskTable({
</div>
)}
<Table>
<TableHeader>
<TableRow>
<SortableHeader
label="Title"
field="title"
sortConfig={sortConfig}
onSort={handleSort}
/>
<SortableHeader
label="Status"
field="status"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="whitespace-nowrap">Git</TableHead>
<SortableHeader
label="Team"
field="team"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="whitespace-nowrap">
Project / Product
</TableHead>
<SortableHeader
label="Priority"
field="priority"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<SortableHeader
label="Assigned To"
field="assigned_to"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<SortableHeader
label="Created"
field="created_at"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TaskTableSkeleton />
) : paginatedTasks.length === 0 ? (
<TaskTableEmpty />
) : (
paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
<ResponsiveTable
table={
<Table>
<TableHeader>
<TableRow>
<SortableHeader
label="Title"
field="title"
sortConfig={sortConfig}
onSort={handleSort}
/>
<SortableHeader
label="Status"
field="status"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="whitespace-nowrap">Git</TableHead>
<SortableHeader
label="Team"
field="team"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="whitespace-nowrap">
Project / Product
</TableHead>
<SortableHeader
label="Priority"
field="priority"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<SortableHeader
label="Assigned To"
field="assigned_to"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<SortableHeader
label="Created"
field="created_at"
sortConfig={sortConfig}
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TaskTableSkeleton />
) : paginatedTasks.length === 0 ? (
<TaskTableEmpty />
) : (
paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
const handleRowClick = (e: React.MouseEvent) => {
// Don't toggle if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest("a") ||
target.closest("button") ||
target.closest('[role="button"]') ||
target.closest("[data-no-expand]")
) {
return;
}
if (hasChildren) {
toggleExpand(task.id);
}
};
const handleRowClick = (e: React.MouseEvent) => {
// Don't toggle if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest("a") ||
target.closest("button") ||
target.closest('[role="button"]') ||
target.closest("[data-no-expand]")
) {
return;
}
if (hasChildren) {
toggleExpand(task.id);
}
};
return (
<TableRow
key={task.id}
className={cn(
"hover:bg-muted/50",
node.depth > 0 && "bg-muted/20",
hasChildren && "cursor-pointer",
)}
onClick={handleRowClick}
>
<TableCell className="max-w-[22rem]">
<div
className="flex items-center gap-1 min-w-0"
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
) : (
<span className="w-5 shrink-0" />
return (
<TableRow
key={task.id}
className={cn(
"hover:bg-muted/50",
node.depth > 0 && "bg-muted/20",
hasChildren && "cursor-pointer",
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="block hover:underline min-w-0"
>
<div className="font-medium flex items-center gap-2 min-w-0">
<span className="truncate" title={task.title}>
{task.title}
</span>
{task.batch_id && !task.parent_task_id && (
<Badge
variant="outline"
className="text-xs shrink-0 border-primary/50 text-primary"
>
MegaTask
</Badge>
)}
{childCount > 0 && (
<Badge
variant="secondary"
className="text-xs shrink-0"
>
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
)}
</div>
</Link>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell className="whitespace-nowrap">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</TableCell>
<TableCell className="capitalize whitespace-nowrap">
{task.team.replace(/_/g, " ")}
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id && productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className={
(priorityColors[task.priority] ?? priorityColors[2]) +
" text-xs"
}
onClick={handleRowClick}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<TaskActions task={task} />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<TableCell className="max-w-[22rem]">
<div
className="flex items-center gap-1 min-w-0"
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
) : (
<span className="w-5 shrink-0" />
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="block hover:underline min-w-0"
>
<div className="font-medium flex items-center gap-2 min-w-0">
<span className="truncate" title={task.title}>
{task.title}
</span>
{task.batch_id && !task.parent_task_id && (
<Badge
variant="outline"
className="text-xs shrink-0 border-primary/50 text-primary"
>
MegaTask
</Badge>
)}
{childCount > 0 && (
<Badge
variant="secondary"
className="text-xs shrink-0"
>
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
)}
</div>
</Link>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell className="whitespace-nowrap">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</TableCell>
<TableCell className="capitalize whitespace-nowrap">
{task.team.replace(/_/g, " ")}
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id &&
productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className={
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<TaskActions task={task} />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
}
cards={
isLoading ? (
<TaskCardSkeletons />
) : paginatedTasks.length === 0 ? (
<ResponsiveTableCardEmpty className="m-3">
No tasks found
</ResponsiveTableCardEmpty>
) : (
<ResponsiveTableCardList className="p-3">
{paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
return (
<ResponsiveTableCard
key={task.id}
style={{ marginLeft: `${node.depth * 1}rem` }}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{hasChildren && (
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="min-w-0 truncate font-medium hover:underline"
title={task.title}
>
{task.title}
</Link>
</div>
{(task.batch_id && !task.parent_task_id) ||
childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && (
<Badge
variant="outline"
className="border-primary/50 text-xs text-primary"
>
MegaTask
</Badge>
)}
{childCount > 0 && (
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
)}
</div>
) : null}
</div>
<TaskActions task={task} />
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Status">
<TaskStatusBadge status={task.status} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Git">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Team">
<span className="capitalize">
{task.team.replace(/_/g, " ")}
</span>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Project">
{task.project_id && projectNames[task.project_id]
? projectNames[task.project_id]
: task.product_id && productNames[task.product_id]
? `${productNames[task.product_id]} (product)`
: "—"}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Priority">
<Badge
className={
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Assigned">
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Created">
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
);
})}
</ResponsiveTableCardList>
)
}
/>
{/* Pagination Controls */}
{!isLoading && tasks && tasks.length > 0 && (
<div className="flex items-center justify-end gap-4 px-4 py-3 border-t">
<div className="flex flex-wrap items-center justify-end gap-4 px-4 py-3 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows:</span>
<Select
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "../responsive-table";
// Deterministic matchMedia stub — jsdom has none. `matches` is controlled
// per-test via the module-level flag so useIsMobile resolves synchronously
// within the component's mount effect.
let mockMatches = false;
beforeEach(() => {
mockMatches = false;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: mockMatches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})) as unknown as typeof window.matchMedia;
});
describe("ResponsiveTable", () => {
it("renders the desktop table branch when the viewport does not match mobile", () => {
mockMatches = false;
render(
<ResponsiveTable
table={<div data-testid="desktop-table">table</div>}
cards={<div data-testid="mobile-cards">cards</div>}
/>,
);
expect(screen.getByTestId("desktop-table")).toBeInTheDocument();
expect(screen.queryByTestId("mobile-cards")).not.toBeInTheDocument();
});
it("renders only the card branch below the breakpoint — never both at once", () => {
mockMatches = true;
render(
<ResponsiveTable
table={<div data-testid="desktop-table">table</div>}
cards={<div data-testid="mobile-cards">cards</div>}
/>,
);
expect(screen.getByTestId("mobile-cards")).toBeInTheDocument();
expect(screen.queryByTestId("desktop-table")).not.toBeInTheDocument();
});
});
describe("ResponsiveTableCard building blocks", () => {
it("renders a card with labeled rows", () => {
render(
<ResponsiveTableCardList>
<ResponsiveTableCard>
<ResponsiveTableCardRow label="Status">Active</ResponsiveTableCardRow>
</ResponsiveTableCard>
</ResponsiveTableCardList>,
);
expect(screen.getByText("Status")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { cn } from "@/lib/utils";
interface ResponsiveTableProps {
/** The existing desktop `<Table>`, rendered unchanged at `md` and up. */
table: React.ReactNode;
/** The stacked card list, rendered below `md` instead of the table. */
cards: React.ReactNode;
}
/**
* Single shared switch point for every table call site: below `md`, a data
* table becomes a stacked card list instead (see ResponsiveTableCard). Only
* one of the two subtrees mounts at a time never the desktop table AND its
* card equivalent together so an interactive row (dropdowns, buttons) is
* never duplicated in the DOM.
*
* `useIsMobile` defaults to `false` on the server and on the first client
* render, so this always resolves to `table` until after mount SSR and the
* hydration pass render identical markup, then the card branch takes over a
* tick later on an actual mobile viewport.
*/
export function ResponsiveTable({ table, cards }: ResponsiveTableProps) {
const isMobile = useIsMobile();
return <>{isMobile ? cards : table}</>;
}
function ResponsiveTableCardList({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-table-cards"
className={cn("space-y-3", className)}
{...props}
/>
);
}
function ResponsiveTableCard({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-table-card"
className={cn("rounded-lg border bg-card p-4", className)}
{...props}
/>
);
}
/** One labeled key/value row inside a card — the mobile analog of a table cell. */
function ResponsiveTableCardRow({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center justify-between gap-3 py-1 text-sm",
className,
)}
>
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 text-right">{children}</span>
</div>
);
}
function ResponsiveTableCardEmpty({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-table-cards-empty"
className={cn(
"rounded-lg border border-dashed p-8 text-center text-muted-foreground",
className,
)}
{...props}
/>
);
}
export {
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
ResponsiveTableCardEmpty,
};
+7 -1
View File
@@ -26,7 +26,13 @@ function TabsList({
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
// max-w-full + overflow-x-auto: a TabsList wider than its container
// (many tabs on a narrow viewport) scrolls horizontally in place
// instead of overflowing the page. Snap + hidden scrollbar make the
// scroll read as a deliberate swipeable strip, not a layout bug.
// justify-center-SAFE: plain center + overflow clips the first tab
// unreachably off the left edge; safe alignment falls back to start.
"bg-muted text-muted-foreground inline-flex h-9 w-fit max-w-full items-center justify-center-safe rounded-lg p-[3px] overflow-x-auto snap-x [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>[data-slot=tabs-trigger]]:snap-start",
className,
)}
{...props}
@@ -12,6 +12,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import {
GitBranch,
@@ -84,69 +90,132 @@ export function WorkSessionTable({
}
return (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Branch</TableHead>
<TableHead>Task</TableHead>
<TableHead>Status</TableHead>
<TableHead>PR</TableHead>
<TableHead>Started</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<ResponsiveTable
table={
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Branch</TableHead>
<TableHead>Task</TableHead>
<TableHead>Status</TableHead>
<TableHead>PR</TableHead>
<TableHead>Started</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.id}>
<TableCell>
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<Link
prefetch={false}
href={`/work-sessions/${session.id}`}
className="font-medium hover:underline font-mono text-sm"
>
{session.branch_name}
</Link>
</div>
</TableCell>
<TableCell>
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
{session.task_id.slice(0, 8)}...
</Link>
</TableCell>
<TableCell>{getStatusBadge(session.status)}</TableCell>
<TableCell>
{session.has_pr ? (
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
PR Open
</Badge>
) : (
<span className="text-sm text-muted-foreground">
No PR
</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<Link
href={`/work-sessions/${session.id}`}
prefetch={false}
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
}
cards={
<ResponsiveTableCardList>
{sessions.map((session) => (
<TableRow key={session.id}>
<TableCell>
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<ResponsiveTableCard key={session.id}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<Link
prefetch={false}
href={`/work-sessions/${session.id}`}
className="font-medium hover:underline font-mono text-sm"
className="truncate font-mono text-sm font-medium hover:underline"
>
{session.branch_name}
</Link>
</div>
</TableCell>
<TableCell>
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
{session.task_id.slice(0, 8)}...
</Link>
</TableCell>
<TableCell>{getStatusBadge(session.status)}</TableCell>
<TableCell>
{session.has_pr ? (
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
PR Open
</Badge>
) : (
<span className="text-sm text-muted-foreground">No PR</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" className="shrink-0">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</TableCell>
</TableRow>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Task">
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{session.task_id.slice(0, 8)}...
</Link>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status">
{getStatusBadge(session.status)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="PR">
{session.has_pr ? (
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
PR Open
</Badge>
) : (
<span className="text-muted-foreground">No PR</span>
)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Started">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</TableBody>
</Table>
</div>
</ResponsiveTableCardList>
}
/>
);
}
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useIsMobile } from "../use-is-mobile";
// Minimal matchMedia stub — jsdom doesn't implement it. Tracks listeners so
// tests can flip `matches` and fire a synthetic "change" event.
//
// Note on SSR/hydration safety: useIsMobile is built on useSyncExternalStore
// with a `getServerSnapshot` that always returns `false`. React uses that same
// value for the server render AND the client's first (pre-hydration-commit)
// render, so there is no mismatch to reproduce here — RTL's `renderHook` only
// ever does a client render, so it exercises `getSnapshot` (the real
// matchMedia read), never the server path. That guarantee is structural
// (React's contract for the hook), not something a jsdom unit test observes.
function installMatchMedia(initialMatches: boolean) {
const listeners = new Set<(e: MediaQueryListEvent) => void>();
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: initialMatches,
media: query,
addEventListener: (_: "change", cb: (e: MediaQueryListEvent) => void) => {
listeners.add(cb);
},
removeEventListener: (
_: "change",
cb: (e: MediaQueryListEvent) => void,
) => {
listeners.delete(cb);
},
})) as unknown as typeof window.matchMedia;
return {
fireChange(matches: boolean) {
initialMatches = matches;
listeners.forEach((cb) => cb({ matches } as MediaQueryListEvent));
},
};
}
describe("useIsMobile", () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
window.matchMedia = originalMatchMedia;
vi.restoreAllMocks();
});
it("resolves to the real matchMedia value on render", () => {
installMatchMedia(true);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it("resolves false when the query does not match", () => {
installMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("reacts to a live matchMedia change (viewport resize)", () => {
const { fireChange } = installMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
act(() => {
fireChange(true);
});
expect(result.current).toBe(true);
});
it("builds the query from a custom breakpoint", () => {
installMatchMedia(false);
renderHook(() => useIsMobile(1024));
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 1023px)");
});
it("defaults to the md breakpoint (768px) when none is passed", () => {
installMatchMedia(false);
renderHook(() => useIsMobile());
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
});
});
+1
View File
@@ -36,3 +36,4 @@ export * from "./use-journals";
export * from "./use-projects";
export * from "./use-work-sessions";
export * from "./use-usage";
export * from "./use-auth";
+27
View File
@@ -0,0 +1,27 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { authApi } from "@/lib/api/auth";
export const authKeys = {
status: ["auth", "status"] as const,
};
export function useAuthStatus() {
return useQuery({
queryKey: authKeys.status,
queryFn: () => authApi.status(),
staleTime: 60000,
});
}
export function useLogin() {
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authApi.login(email, password),
});
}
export function useLogout() {
return useMutation({
mutationFn: () => authApi.logout(),
});
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
const DEFAULT_BREAKPOINT_PX = 768; // Tailwind `md`
/**
* True below `breakpointPx` (default the Tailwind `md` breakpoint).
* `useSyncExternalStore`'s server snapshot (`false`, desktop) is also what
* React uses for the client's first render before hydration commits so SSR
* and the initial hydration pass render identical markup, and the real
* matchMedia value only takes over a tick later. No manual
* useState/useEffect pairing, so there's nothing to cascade-render.
*/
export function useIsMobile(breakpointPx: number = DEFAULT_BREAKPOINT_PX) {
const query = `(max-width: ${breakpointPx - 1}px)`;
// Memoized per query: a new subscribe identity each render would make
// useSyncExternalStore tear down + re-attach the matchMedia listener on
// every render of every consumer.
const subscribe = useCallback(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false,
);
}
+31
View File
@@ -0,0 +1,31 @@
import api from "./client";
// Matches roboco.api.auth.routes.auth_status's response shape.
export interface AuthStatus {
cloud_auth_enabled: boolean;
}
export const authApi = {
// Always available regardless of the flag — the panel middleware and the
// login page both probe this before deciding whether to gate/redirect.
status: async (): Promise<AuthStatus> => {
const { data } = await api.get<AuthStatus>("/auth/status");
return data;
},
// FastAPI Users' cookie-login route expects an OAuth2 form body
// (username/password), not JSON — the session cookie rides back on the
// response, set by the browser automatically.
login: async (email: string, password: string): Promise<void> => {
const body = new URLSearchParams();
body.set("username", email);
body.set("password", password);
await api.post("/auth/login", body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
},
logout: async (): Promise<void> => {
await api.post("/auth/logout");
},
};
+28
View File
@@ -20,6 +20,10 @@ const api: AxiosInstance = axios.create({
"Content-Type": "application/json",
},
timeout: 60000, // Increased to 60s for long operations like reindexing
// Rides the cloud-auth session cookie. Harmless when cloud auth is off
// (same-origin requests already carry cookies regardless), and required
// for a cross-origin dev setup that talks to the backend directly.
withCredentials: true,
});
// Request interceptor to add auth headers and logging
@@ -42,6 +46,29 @@ api.interceptors.request.use(
},
);
// A 401 only means "log in" when cloud auth is actually on. In header-trust /
// secure mode (cloud auth off) a 401 is a misconfigured agent token, not a
// missing session — bouncing to /login would dead-end on a page whose backend
// route isn't mounted. Probe the public status endpoint (a bare fetch so it
// doesn't re-enter this interceptor) and only redirect when cloud auth is on.
async function redirectToLoginIfCloudAuth(): Promise<void> {
if (typeof window === "undefined" || window.location.pathname === "/login") {
return;
}
try {
const res = await fetch(`${API_URL}/auth/status`, {
credentials: "include",
});
if (!res.ok) return;
const data = (await res.json()) as { cloud_auth_enabled?: boolean };
if (data.cloud_auth_enabled) {
window.location.href = "/login";
}
} catch {
// Can't confirm cloud auth is on -> don't dead-end the user on /login.
}
}
// Response interceptor for comprehensive error handling
api.interceptors.response.use(
(response) => {
@@ -128,6 +155,7 @@ api.interceptors.response.use(
);
} else if (status === 401) {
console.error("[API] Unauthorized - check API authentication headers");
void redirectToLoginIfCloudAuth();
} else if (status === 403) {
console.error(
"[API] Forbidden - insufficient permissions for this action",
+11
View File
@@ -24,3 +24,14 @@ export type {
} from "./release";
export { playbooksApi } from "./playbooks";
export type { Playbook } from "./playbooks";
export { authApi } from "./auth";
export type { AuthStatus } from "./auth";
export { xApi } from "./x";
export type {
XPost,
XMentionRef,
XPostExecuteResult,
XCredentialsStatus,
} from "./x";
export { roadmapApi } from "./roadmap";
export type { RoadmapCycle, RoadmapItem, RoadmapItemActionResult } from "./roadmap";
+1
View File
@@ -92,6 +92,7 @@ export const projectsApi = {
ci_watch_workflow: null,
dep_update_command: null,
dep_update_paths: null,
sandbox_services: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
+65
View File
@@ -0,0 +1,65 @@
import api from "./client";
// ---------------------------------------------------------------------------
// Board roadmap engine — the Product Owner authors a themed cycle of roadmap
// items (a goal + 3-7 drafts); the CEO approves or rejects each item
// individually here. Approving materializes a BACKLOG task; nothing starts
// automatically.
// ---------------------------------------------------------------------------
export interface RoadmapItem {
id: string;
title: string;
description: string;
acceptance_criteria: string[];
project_slug: string;
team: string;
priority: number;
rationale: string;
status: "proposed" | "approved" | "rejected";
reject_reason?: string | null;
materialized_task_id?: string | null;
}
export interface RoadmapCycle {
task_id: string;
title: string;
status: string;
goal: string;
items: RoadmapItem[];
}
export interface RoadmapItemActionResult {
status: string;
item_id: string;
materialized_task_id?: string | null;
detail: string;
}
export const roadmapApi = {
listCycles: async (): Promise<RoadmapCycle[]> => {
const { data } = await api.get<RoadmapCycle[]>("/roadmap/cycles");
return data;
},
approveItem: async (
taskId: string,
itemId: string,
): Promise<RoadmapItemActionResult> => {
const { data } = await api.post<RoadmapItemActionResult>(
`/roadmap/cycles/${taskId}/items/${itemId}/approve`,
{},
);
return data;
},
rejectItem: async (
taskId: string,
itemId: string,
reason: string,
): Promise<RoadmapItemActionResult> => {
const { data } = await api.post<RoadmapItemActionResult>(
`/roadmap/cycles/${taskId}/items/${itemId}/reject`,
{ reason },
);
return data;
},
};
+75
View File
@@ -0,0 +1,75 @@
import api from "./client";
// ---------------------------------------------------------------------------
// X (Twitter) engine — held release-post + mention-reply drafts the CEO edits
// and approves (posts) or rejects (cancels) in the panel. Nothing posts until
// an explicit approve; credentials are write-only (the API never returns
// the stored secrets).
// ---------------------------------------------------------------------------
export interface XMentionRef {
id: string;
author_id: string;
text: string;
}
export interface XPost {
task_id: string;
source: "x_post" | "x_reply";
title: string;
status: string;
body: string;
char_count: number;
release_version?: string | null;
mention?: XMentionRef | null;
reject_reason?: string | null;
}
export interface XPostExecuteResult {
status: string;
tweet_id?: string | null;
detail: string;
}
export interface XCredentialsStatus {
has_credentials: boolean;
}
export const xApi = {
listPosts: async (): Promise<XPost[]> => {
const { data } = await api.get<XPost[]>("/x/posts");
return data;
},
approve: async (
taskId: string,
editedBody?: string,
): Promise<XPostExecuteResult> => {
const { data } = await api.post<XPostExecuteResult>(
`/x/posts/${taskId}/approve`,
editedBody ? { edited_body: editedBody } : {},
);
return data;
},
reject: async (taskId: string, reason: string): Promise<XPost> => {
const { data } = await api.post<XPost>(`/x/posts/${taskId}/reject`, {
reason,
});
return data;
},
getCredentialsStatus: async (): Promise<XCredentialsStatus> => {
const { data } = await api.get<XCredentialsStatus>("/x/credentials");
return data;
},
setCredentials: async (creds: {
api_key: string;
api_secret: string;
access_token: string;
access_token_secret: string;
}): Promise<XCredentialsStatus> => {
const { data } = await api.post<XCredentialsStatus>(
"/x/credentials",
creds,
);
return data;
},
};
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Server-side only (no NEXT_PUBLIC_ prefix — never reaches the browser
// bundle). Runs inside the panel container and reaches the orchestrator over
// the docker-internal network (roboco_default), not through nginx — a request
// back out through the panel's own public origin would be a container calling
// itself over the internet. Defaults to the compose service name.
const INTERNAL_API_URL =
process.env.INTERNAL_API_URL || "http://roboco-orchestrator:8000/api";
// Must match roboco.api.auth.backend.SESSION_COOKIE_NAME.
const SESSION_COOKIE_NAME = "roboco_session";
// The probe must never block navigation: a slow/unreachable backend fails
// open to "cloud auth off" (the safe default — off is what every deploy
// starts on), not a stuck redirect.
const STATUS_PROBE_TIMEOUT_MS = 1500;
async function isCloudAuthEnabled(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS);
const res = await fetch(`${INTERNAL_API_URL}/auth/status`, {
signal: controller.signal,
cache: "no-store",
});
clearTimeout(timer);
if (!res.ok) return false;
const data = (await res.json()) as { cloud_auth_enabled?: boolean };
return data.cloud_auth_enabled === true;
} catch {
return false;
}
}
export async function proxy(request: NextRequest) {
if (!(await isCloudAuthEnabled())) {
return NextResponse.next();
}
if (!request.cookies.has(SESSION_COOKIE_NAME)) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
// Everything except the login page itself (avoids a redirect loop), API
// routes (nginx routes /api/* straight to the orchestrator in prod — this
// never sees them there; excluded defensively for a bare `next start`),
// Next's internal asset paths, and the static icon files at the app root.
matcher: [
"/((?!login|api|_next/static|_next/image|favicon.ico|apple-icon.png|icon.png).*)",
],
};
+18
View File
@@ -1 +1,19 @@
import "@testing-library/jest-dom";
// jsdom has no matchMedia implementation. useIsMobile (and anything built on
// it, e.g. ResponsiveTable) calls window.matchMedia unconditionally in an
// effect, so every test needs at least a default (non-matching / desktop)
// stub — individual tests can still override window.matchMedia themselves
// for mobile-branch assertions.
if (typeof window !== "undefined" && !window.matchMedia) {
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
});
}
+2
View File
@@ -1133,6 +1133,7 @@ export interface Project {
ci_watch_workflow: string | null;
dep_update_command: string | null;
dep_update_paths: string[] | null;
sandbox_services: string[] | null;
// Runtime state
workspace_path: string | null;
last_synced_at: string | null;
@@ -1180,6 +1181,7 @@ export interface ProjectUpdate {
ci_watch_workflow?: string;
dep_update_command?: string;
dep_update_paths?: string[];
sandbox_services?: string[];
}
export interface ProjectSummary {