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