P0: Next.js shell — tokens, motion, SlidePanel, LeftRail, auth plumbing
- Tailwind v4 @theme block encoding all spec §2 tokens; full §3 keyframe inventory with prefers-reduced-motion handling; Geist vendored (air-gap) - SlidePanel width-animation primitive (component-tested: exit-transition unmount, fixed-width inner content, a11y region semantics) - session-key.ts mirroring the Rust codec + URL-param encoding; nuqs panel-params with spec ?sessions=1 flag shape and routines->scheduled alias - Zod-typed API client; httpOnly cookie session bridge (/auth/session); login page; (workspace) layout with LeftRail roster/nav/user; Team and Credits pages on real endpoints (+ GET /api/team/members in tc-api) - Vitest + Testing Library harness (26 tests); ESLint max-lines 1250 + no-warning-comments mirroring the CI gates Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c4349bf292
commit
172a3c8fed
@@ -32,6 +32,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/auth/logout", post(routes::auth::logout))
|
.route("/api/auth/logout", post(routes::auth::logout))
|
||||||
.route("/api/user/me", get(routes::identity::me))
|
.route("/api/user/me", get(routes::identity::me))
|
||||||
.route("/api/team/claws", get(routes::team::claws))
|
.route("/api/team/claws", get(routes::team::claws))
|
||||||
|
.route("/api/team/members", get(routes::team::members))
|
||||||
.route("/api/team/credits", get(routes::team::credits))
|
.route("/api/team/credits", get(routes::team::credits))
|
||||||
.route("/api/team/permissions", get(routes::team::permissions))
|
.route("/api/team/permissions", get(routes::team::permissions))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tc_domain::Agent;
|
use tc_domain::{Agent, User};
|
||||||
|
|
||||||
use crate::{ApiError, AppState, Authed};
|
use crate::{ApiError, AppState, Authed};
|
||||||
|
|
||||||
|
/// Members table for the Team page (§8.3).
|
||||||
|
pub async fn members(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<Vec<User>>, ApiError> {
|
||||||
|
let members = tc_db::repo::users::list_by_workspace(&state.pool, user.workspace_id).await?;
|
||||||
|
Ok(Json(members))
|
||||||
|
}
|
||||||
|
|
||||||
/// The left-rail agent roster (§4).
|
/// The left-rail agent roster (§4).
|
||||||
pub async fn claws(
|
pub async fn claws(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -246,3 +246,27 @@ async fn logout_revokes_the_session() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(me.status(), 401);
|
assert_eq!(me.status(), 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn team_members_lists_workspace_users() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let (_, owner, _) = seed(&pool).await;
|
||||||
|
let server = serve(pool).await;
|
||||||
|
let token = login(&server).await;
|
||||||
|
|
||||||
|
let members: Value = server
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/team/members", server.base))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let list = members.as_array().unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0]["id"], owner.id.to_string());
|
||||||
|
assert_eq!(list[0]["role"], "owner");
|
||||||
|
assert_eq!(list[0]["display_name"], "Owner");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Project-wide discipline: files stay under 1,250 lines and no
|
||||||
|
// deferred-work markers ever land (mirrors ci/check-*.sh gates).
|
||||||
|
"max-lines": ["error", { max: 1250, skipBlankLines: false, skipComments: false }],
|
||||||
|
"no-warning-comments": [
|
||||||
|
"error",
|
||||||
|
{ terms: ["todo", "fixme", "xxx", "hack"], location: "anywhere" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7928
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"geist": "^1.7.2",
|
||||||
|
"next": "16.2.9",
|
||||||
|
"nuqs": "^2.8.9",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.9",
|
||||||
|
"happy-dom": "^20.10.2",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,25 @@
|
|||||||
|
import { fetchCredits } from "@/lib/api/team";
|
||||||
|
|
||||||
|
// Credits page (§8.4): available balance; purchase and usage land in P5.
|
||||||
|
export default async function CreditsPage() {
|
||||||
|
const credits = await fetchCredits();
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-8 py-12">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Credits</h1>
|
||||||
|
<p className="pt-1 text-sm text-muted-foreground">
|
||||||
|
Manage balance, subscriptions, and usage
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 rounded-(--radius) border border-border bg-surface-warm p-6 shadow-(--shadow-card)">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Available credits
|
||||||
|
</p>
|
||||||
|
<p className="pt-2 font-mono text-xxxl font-semibold">
|
||||||
|
{credits.available.toLocaleString("en-US")}
|
||||||
|
</p>
|
||||||
|
<p className="pt-2 text-xs text-muted-foreground">
|
||||||
|
All credits never expire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { LeftRail } from "@/components/shell/LeftRail";
|
||||||
|
import { ApiAuthError } from "@/lib/api/http";
|
||||||
|
import { fetchClaws, fetchMe } from "@/lib/api/team";
|
||||||
|
import type { Agent, User } from "@/lib/api/schemas";
|
||||||
|
|
||||||
|
// Every workspace page shares the persistent rail (§4). This layout reads
|
||||||
|
// only path-level data — search params stay client-side (see panel-params).
|
||||||
|
export default async function WorkspaceLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
let user: User;
|
||||||
|
let roster: Agent[];
|
||||||
|
try {
|
||||||
|
[user, roster] = await Promise.all([fetchMe(), fetchClaws()]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiAuthError) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh">
|
||||||
|
<LeftRail user={user} roster={roster} />
|
||||||
|
<main className="min-w-0 flex-1">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Workspace home: the empty-shell landing until agent chat arrives (P1).
|
||||||
|
export default function WorkspaceHome() {
|
||||||
|
return (
|
||||||
|
<section className="flex h-dvh flex-col items-center justify-center gap-2 motion-safe:animate-[route-fade-in_var(--duration-normal)_var(--ease-app)]">
|
||||||
|
<h1 className="text-xxxl font-semibold tracking-tight">TeamClaw</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pick a claw from the rail, or create one to get started.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { fetchMembers } from "@/lib/api/team";
|
||||||
|
|
||||||
|
// Team page (§8.3): members table.
|
||||||
|
export default async function TeamPage() {
|
||||||
|
const members = await fetchMembers();
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-8 py-12">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
|
||||||
|
<p className="pt-1 text-sm text-muted-foreground">
|
||||||
|
{members.length} {members.length === 1 ? "member" : "members"} in your
|
||||||
|
workspace
|
||||||
|
</p>
|
||||||
|
<table className="mt-8 w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||||
|
<th className="py-2 font-medium">Name</th>
|
||||||
|
<th className="py-2 font-medium">Email</th>
|
||||||
|
<th className="py-2 font-medium">Joined</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr key={member.id} className="border-b border-border/50">
|
||||||
|
<td className="py-3">
|
||||||
|
{member.display_name}
|
||||||
|
{member.role === "owner" && (
|
||||||
|
<span className="ml-2 rounded-(--radius-button) bg-surface-warm-muted px-2 py-0.5 text-xxs text-muted-foreground">
|
||||||
|
Owner
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-muted-foreground">{member.email}</td>
|
||||||
|
<td className="py-3 text-muted-foreground">
|
||||||
|
{new Date(member.created_at).toLocaleDateString("en-US", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Session bridge: exchanges credentials with the Rust backend and stores the
|
||||||
|
// bearer token in an httpOnly cookie so client JavaScript can never read it.
|
||||||
|
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { apiOrigin, TOKEN_COOKIE } from "@/lib/api/http";
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json();
|
||||||
|
const upstream = await fetch(`${apiOrigin()}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return NextResponse.json({ error: "invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { token } = (await upstream.json()) as { token: string };
|
||||||
|
|
||||||
|
const response = new NextResponse(null, { status: 204 });
|
||||||
|
response.cookies.set({
|
||||||
|
name: TOKEN_COOKIE,
|
||||||
|
value: token,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const token = request.cookies.get(TOKEN_COOKIE)?.value;
|
||||||
|
if (token) {
|
||||||
|
await fetch(`${apiOrigin()}/api/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const response = new NextResponse(null, { status: 204 });
|
||||||
|
response.cookies.delete(TOKEN_COOKIE);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "../styles/motion.css";
|
||||||
|
|
||||||
|
/* Design tokens, spec §2. Dark-first; coral brand accent. */
|
||||||
|
@theme {
|
||||||
|
--color-background: #0a0a0a;
|
||||||
|
--color-foreground: #fafafa;
|
||||||
|
--color-card: #0a0a0a;
|
||||||
|
--color-popover: #0a0a0a;
|
||||||
|
--color-border: #262626;
|
||||||
|
--color-input: #262626;
|
||||||
|
--color-muted: #262626;
|
||||||
|
--color-secondary: #262626;
|
||||||
|
--color-muted-foreground: #a3a3a3;
|
||||||
|
--color-subtle: #141414;
|
||||||
|
--color-surface-warm: #141414;
|
||||||
|
--color-surface-warm-muted: #1f1f1f;
|
||||||
|
--color-accent: #f96565;
|
||||||
|
--color-coral-light: #fa7575;
|
||||||
|
--color-coral-dark: #e85555;
|
||||||
|
--color-destructive: #7f1d1d;
|
||||||
|
--color-primary: #fafafa;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--radius-button: 9999px;
|
||||||
|
|
||||||
|
--font-sans: var(--font-geist), system-ui, sans-serif;
|
||||||
|
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||||
|
|
||||||
|
--text-xxs: 10px;
|
||||||
|
--text-xxxl: 36px;
|
||||||
|
|
||||||
|
/* Layout widths (§4, §6, §7): rail 176, sessions 208, computer 448. */
|
||||||
|
--spacing-rail: 176px;
|
||||||
|
--spacing-panel-sessions: 208px;
|
||||||
|
--spacing-panel-device: 448px;
|
||||||
|
|
||||||
|
--ease-app: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
--ease-out-app: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--duration-fast: 0.15s;
|
||||||
|
--duration-normal: 0.25s;
|
||||||
|
--duration-slow: 0.4s;
|
||||||
|
|
||||||
|
/* Shadow set named by §2; exact values locked via visual snapshots. */
|
||||||
|
--shadow-bubble: 0 1px 2px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-button: 0 1px 2px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||||
|
--shadow-card: 0 2px 8px rgb(0 0 0 / 0.45);
|
||||||
|
--shadow-dialog: 0 12px 40px rgb(0 0 0 / 0.6);
|
||||||
|
--shadow-popover: 0 6px 24px rgb(0 0 0 / 0.55);
|
||||||
|
--shadow-dock-tile: 0 2px 6px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||||
|
--shadow-cta: 0 4px 16px color-mix(in srgb, #f96565 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
// Vendored variable fonts (src/fonts) — zero external requests, so the same
|
||||||
|
// build serves air-gapped installs.
|
||||||
|
const geist = localFont({
|
||||||
|
src: "../fonts/Geist-Variable.woff2",
|
||||||
|
variable: "--font-geist",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = localFont({
|
||||||
|
src: "../fonts/GeistMono-Variable.woff2",
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "TeamClaw",
|
||||||
|
description: "Your team of AI coworkers",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}>
|
||||||
|
<body className="min-h-full antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-mono text-xl font-semibold tracking-tight">
|
||||||
|
teamclaw
|
||||||
|
</h1>
|
||||||
|
<p className="pt-1 text-sm text-muted-foreground">
|
||||||
|
Sign in to your workspace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LoginForm />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setFailed(false);
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
const res = await fetch("/auth/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: data.get("email"),
|
||||||
|
password: data.get("password"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(false);
|
||||||
|
setFailed(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={`flex w-full max-w-sm flex-col gap-3 ${
|
||||||
|
failed ? "motion-safe:animate-[shake_0.4s_var(--ease-app)]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{failed && (
|
||||||
|
<p role="alert" className="text-xs text-accent">
|
||||||
|
Invalid email or password.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="mt-1 rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { AgentRosterItem } from "./AgentRosterItem";
|
||||||
|
|
||||||
|
function agent(status: Agent["status"]): Agent {
|
||||||
|
return {
|
||||||
|
id: "018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e",
|
||||||
|
workspace_id: "018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f",
|
||||||
|
name: "Scout Larson",
|
||||||
|
job_title: "Research Analyst",
|
||||||
|
system_prompt: "",
|
||||||
|
avatar: "scout-1",
|
||||||
|
accent: "#f96565",
|
||||||
|
wallpaper: "dunes",
|
||||||
|
managed_by: "018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AgentRosterItem", () => {
|
||||||
|
it("shows name, initials avatar, and the online dot when online", () => {
|
||||||
|
render(<AgentRosterItem agent={agent("online")} />);
|
||||||
|
expect(screen.getByText("Scout Larson")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("SL")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("status", { name: "Scout Larson is online" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the online dot for offline agents", () => {
|
||||||
|
render(<AgentRosterItem agent={agent("offline")} />);
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
|
||||||
|
/** One agent row in the left rail (§4): avatar, name, online dot. */
|
||||||
|
export function AgentRosterItem({ agent }: { agent: Agent }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-foreground hover:bg-surface-warm">
|
||||||
|
<Avatar name={agent.name} accent={agent.accent} size="sm" />
|
||||||
|
<span className="truncate">{agent.name}</span>
|
||||||
|
{agent.status === "online" && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={`${agent.name} is online`}
|
||||||
|
className="ml-auto size-1.5 shrink-0 rounded-full bg-green-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { GlobalNavItem } from "./GlobalNavItem";
|
||||||
|
|
||||||
|
describe("GlobalNavItem", () => {
|
||||||
|
it("marks the active item with aria-current and the coral accent", () => {
|
||||||
|
render(<GlobalNavItem href="/team" label="Team" active />);
|
||||||
|
const link = screen.getByRole("link", { name: "Team" });
|
||||||
|
expect(link).toHaveAttribute("aria-current", "page");
|
||||||
|
expect(link.className).toContain("text-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inactive items muted without aria-current", () => {
|
||||||
|
render(<GlobalNavItem href="/credits" label="Credits" active={false} />);
|
||||||
|
const link = screen.getByRole("link", { name: "Credits" });
|
||||||
|
expect(link).not.toHaveAttribute("aria-current");
|
||||||
|
expect(link.className).toContain("text-muted-foreground");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface GlobalNavItemProps {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
/** Computed by the shell from the current pathname; active uses coral (§4). */
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalNavItem({ href, label, active }: GlobalNavItemProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`block rounded-(--radius) px-2 py-1.5 text-sm transition-colors duration-(--duration-fast) ${
|
||||||
|
active
|
||||||
|
? "text-accent"
|
||||||
|
: "text-muted-foreground hover:bg-surface-warm hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Agent, User } from "@/lib/api/schemas";
|
||||||
|
import { AgentRosterItem } from "./AgentRosterItem";
|
||||||
|
import { ShellNav } from "./ShellNav";
|
||||||
|
import { UserMenu } from "./UserMenu";
|
||||||
|
|
||||||
|
interface LeftRailProps {
|
||||||
|
user: User;
|
||||||
|
roster: Agent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The persistent 176px left rail (§4): logo, agent roster, global nav,
|
||||||
|
* current user pinned to the bottom. */
|
||||||
|
export function LeftRail({ user, roster }: LeftRailProps) {
|
||||||
|
return (
|
||||||
|
<aside className="flex h-dvh w-rail shrink-0 flex-col border-r border-border bg-subtle px-2 py-4">
|
||||||
|
<p className="px-2 pb-4 font-mono text-sm font-semibold tracking-tight text-foreground">
|
||||||
|
teamclaw
|
||||||
|
</p>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{roster.length === 0 ? (
|
||||||
|
<p className="px-2 text-xs text-muted-foreground">No claws yet</p>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="Your claws" className="flex flex-col gap-0.5">
|
||||||
|
{roster.map((agent) => (
|
||||||
|
<li key={agent.id}>
|
||||||
|
<AgentRosterItem agent={agent} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 pt-3">
|
||||||
|
<ShellNav />
|
||||||
|
<UserMenu user={user} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { GlobalNavItem } from "./GlobalNavItem";
|
||||||
|
|
||||||
|
/** Global nav entries that exist in the current phase. Skills and Apps join
|
||||||
|
* the rail when their pages land (spec §17 P1/P5). */
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/team", label: "Team" },
|
||||||
|
{ href: "/credits", label: "Credits" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ShellNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<nav aria-label="Global" className="flex flex-col gap-0.5">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<GlobalNavItem
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
label={item.label}
|
||||||
|
active={pathname === item.href || pathname.startsWith(`${item.href}/`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { User } from "@/lib/api/schemas";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
|
||||||
|
/** Current user pinned to the rail bottom (§4), with sign-out. */
|
||||||
|
export function UserMenu({ user }: { user: User }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
setPending(true);
|
||||||
|
await fetch("/auth/session", { method: "DELETE" });
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 border-t border-border px-2 pt-3">
|
||||||
|
<Avatar name={user.display_name} size="sm" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs text-foreground">{user.display_name}</p>
|
||||||
|
<p className="truncate text-xxs text-muted-foreground">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={signOut}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-(--radius-button) px-2 py-1 text-xxs text-muted-foreground hover:bg-surface-warm hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
interface AvatarProps {
|
||||||
|
name: string;
|
||||||
|
/** Per-agent accent hex; defaults to the brand coral. */
|
||||||
|
accent?: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZES = { sm: "size-6 text-xxs", md: "size-8 text-xs", lg: "size-16 text-xl" };
|
||||||
|
|
||||||
|
/** Initials avatar used until generated art assets land (excluded by spec). */
|
||||||
|
export function Avatar({ name, accent, size = "md" }: AvatarProps) {
|
||||||
|
const initials = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]!.toUpperCase())
|
||||||
|
.join("");
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`inline-flex items-center justify-center rounded-full font-medium text-background ${SIZES[size]}`}
|
||||||
|
style={{ backgroundColor: accent || "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { SlidePanel } from "./SlidePanel";
|
||||||
|
|
||||||
|
function panel(open: boolean) {
|
||||||
|
return (
|
||||||
|
<SlidePanel open={open} width={448} label="Computer">
|
||||||
|
<p>panel content</p>
|
||||||
|
</SlidePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function track(container: HTMLElement): HTMLElement {
|
||||||
|
return container.firstElementChild as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// happy-dom's TransitionEvent constructor drops propertyName, so build the
|
||||||
|
// event by hand.
|
||||||
|
function fireTransitionEnd(element: HTMLElement, propertyName: string) {
|
||||||
|
const event = Object.assign(new Event("transitionend", { bubbles: true }), {
|
||||||
|
propertyName,
|
||||||
|
});
|
||||||
|
fireEvent(element, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SlidePanel", () => {
|
||||||
|
it("renders an accessible region at the target width when open", () => {
|
||||||
|
render(panel(true));
|
||||||
|
const region = screen.getByRole("complementary", { name: "Computer" });
|
||||||
|
expect(region.style.width).toBe("448px");
|
||||||
|
expect(screen.getByText("panel content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses to zero width and leaves the a11y tree when closed", () => {
|
||||||
|
const { container, rerender } = render(panel(true));
|
||||||
|
rerender(panel(false));
|
||||||
|
// Closed panels are intentionally invisible to assistive tech.
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("complementary", { name: "Computer" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(track(container).style.width).toBe("0px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content mounted during the exit animation, unmounting on transitionend", () => {
|
||||||
|
const { container, rerender } = render(panel(true));
|
||||||
|
rerender(panel(false));
|
||||||
|
// Mid-animation: still mounted so the exit doesn't visually pop.
|
||||||
|
expect(screen.getByText("panel content")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireTransitionEnd(track(container), "width");
|
||||||
|
expect(screen.queryByText("panel content")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores transitionend from other properties", () => {
|
||||||
|
const { container, rerender } = render(panel(true));
|
||||||
|
rerender(panel(false));
|
||||||
|
fireTransitionEnd(track(container), "border-color");
|
||||||
|
expect(screen.getByText("panel content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never mounts content while closed from the start", () => {
|
||||||
|
render(panel(false));
|
||||||
|
expect(screen.queryByText("panel content")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the inner content at fixed width so children never reflow", () => {
|
||||||
|
render(panel(true));
|
||||||
|
const region = screen.getByRole("complementary", { name: "Computer" });
|
||||||
|
const inner = region.firstElementChild as HTMLElement;
|
||||||
|
expect(inner.style.width).toBe("448px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode, type TransitionEvent } from "react";
|
||||||
|
|
||||||
|
interface SlidePanelProps {
|
||||||
|
open: boolean;
|
||||||
|
/** Final content width in px (176 rail / 208 sessions / 448 computer). */
|
||||||
|
width: number;
|
||||||
|
/** Accessible name for the complementary region. */
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The width-animation slide-out shared by SessionsColumn, DevicePanel, and
|
||||||
|
* the rail collapse (spec §3): the outer track animates `width` so the panel
|
||||||
|
* pushes layout instead of overlaying it, while the inner div keeps the final
|
||||||
|
* width so children never reflow mid-animation. Content stays mounted during
|
||||||
|
* the exit transition and unmounts on `transitionend`.
|
||||||
|
*/
|
||||||
|
export function SlidePanel({
|
||||||
|
open,
|
||||||
|
width,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: SlidePanelProps) {
|
||||||
|
const [mounted, setMounted] = useState(open);
|
||||||
|
|
||||||
|
// Render-phase adjustment: opening mounts content immediately (no effect
|
||||||
|
// tick); unmounting waits for the exit transition below.
|
||||||
|
if (open && !mounted) {
|
||||||
|
setMounted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTransitionEnd(event: TransitionEvent<HTMLDivElement>) {
|
||||||
|
if (event.propertyName === "width" && !open && event.target === event.currentTarget) {
|
||||||
|
setMounted(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="complementary"
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={!open}
|
||||||
|
className={`panel-track ${className ?? ""}`}
|
||||||
|
style={{ width: open ? `${width}px` : "0px" }}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
>
|
||||||
|
<div style={{ width: `${width}px` }} className="h-full">
|
||||||
|
{mounted ? children : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,45 @@
|
|||||||
|
// Server-side fetch against the Rust API. The session token lives in an
|
||||||
|
// httpOnly cookie set by /auth/session and is forwarded as a bearer header.
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
export const TOKEN_COOKIE = "tc_session";
|
||||||
|
|
||||||
|
/** Origin of the Rust backend, reachable from the Next server process. */
|
||||||
|
export function apiOrigin(): string {
|
||||||
|
return process.env.API_ORIGIN ?? "http://127.0.0.1:8080";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when there is no session or the backend rejects it; callers
|
||||||
|
* redirect to /login. */
|
||||||
|
export class ApiAuthError extends Error {
|
||||||
|
constructor(reason: string) {
|
||||||
|
super(`unauthenticated: ${reason}`);
|
||||||
|
this.name = "ApiAuthError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const store = await cookies();
|
||||||
|
const token = store.get(TOKEN_COOKIE)?.value;
|
||||||
|
if (!token) {
|
||||||
|
throw new ApiAuthError("no session cookie");
|
||||||
|
}
|
||||||
|
const res = await fetch(`${apiOrigin()}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...init?.headers, Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new ApiAuthError("session rejected");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${path} failed with status ${res.status}`);
|
||||||
|
}
|
||||||
|
return schema.parse(await res.json());
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Zod schemas mirroring the Rust API responses (spec §13/§14). The backend
|
||||||
|
// is a separate codebase: every response is parsed so drift fails loudly in
|
||||||
|
// tests, not silently in the UI.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const RoleSchema = z.enum(["owner", "member"]);
|
||||||
|
export type Role = z.infer<typeof RoleSchema>;
|
||||||
|
|
||||||
|
export const UserSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspace_id: z.string().uuid(),
|
||||||
|
email: z.string(),
|
||||||
|
role: RoleSchema,
|
||||||
|
display_name: z.string(),
|
||||||
|
created_at: z.string(),
|
||||||
|
});
|
||||||
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
|
||||||
|
export const AgentStatusSchema = z.enum(["provisioning", "online", "offline"]);
|
||||||
|
export type AgentStatus = z.infer<typeof AgentStatusSchema>;
|
||||||
|
|
||||||
|
export const AgentSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspace_id: z.string().uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
job_title: z.string(),
|
||||||
|
system_prompt: z.string(),
|
||||||
|
avatar: z.string(),
|
||||||
|
accent: z.string(),
|
||||||
|
wallpaper: z.string(),
|
||||||
|
managed_by: z.string().uuid(),
|
||||||
|
status: AgentStatusSchema,
|
||||||
|
});
|
||||||
|
export type Agent = z.infer<typeof AgentSchema>;
|
||||||
|
|
||||||
|
export const PermissionsSchema = z.object({
|
||||||
|
role: RoleSchema,
|
||||||
|
can_manage_team: z.boolean(),
|
||||||
|
can_manage_billing: z.boolean(),
|
||||||
|
});
|
||||||
|
export type Permissions = z.infer<typeof PermissionsSchema>;
|
||||||
|
|
||||||
|
export const CreditsSchema = z.object({
|
||||||
|
available: z.number(),
|
||||||
|
});
|
||||||
|
export type Credits = z.infer<typeof CreditsSchema>;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { apiFetch } from "./http";
|
||||||
|
import {
|
||||||
|
AgentSchema,
|
||||||
|
CreditsSchema,
|
||||||
|
PermissionsSchema,
|
||||||
|
UserSchema,
|
||||||
|
type Agent,
|
||||||
|
type Credits,
|
||||||
|
type Permissions,
|
||||||
|
type User,
|
||||||
|
} from "./schemas";
|
||||||
|
|
||||||
|
export function fetchMe(): Promise<User> {
|
||||||
|
return apiFetch(UserSchema, "/api/user/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchClaws(): Promise<Agent[]> {
|
||||||
|
return apiFetch(z.array(AgentSchema), "/api/team/claws");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMembers(): Promise<User[]> {
|
||||||
|
return apiFetch(z.array(UserSchema), "/api/team/members");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCredits(): Promise<Credits> {
|
||||||
|
return apiFetch(CreditsSchema, "/api/team/credits");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPermissions(): Promise<Permissions> {
|
||||||
|
return apiFetch(PermissionsSchema, "/api/team/permissions");
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
APP_IDS,
|
||||||
|
DEVICE_SIZES,
|
||||||
|
panelParsers,
|
||||||
|
resolveAppView,
|
||||||
|
WIZARD_STEPS,
|
||||||
|
} from "./panel-params";
|
||||||
|
|
||||||
|
describe("panelParsers.app", () => {
|
||||||
|
it("accepts every Computer-panel app id from spec §12", () => {
|
||||||
|
const expected = [
|
||||||
|
"browser",
|
||||||
|
"slack",
|
||||||
|
"chat",
|
||||||
|
"skills",
|
||||||
|
"files",
|
||||||
|
"routines",
|
||||||
|
"settings",
|
||||||
|
"apps",
|
||||||
|
];
|
||||||
|
expect([...APP_IDS]).toEqual(expected);
|
||||||
|
for (const id of expected) {
|
||||||
|
expect(panelParsers.app.parse(id)).toBe(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown app ids", () => {
|
||||||
|
expect(panelParsers.app.parse("terminal")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("panelParsers.device", () => {
|
||||||
|
it("accepts full, tablet, and phone with tablet as default", () => {
|
||||||
|
expect([...DEVICE_SIZES]).toEqual(["full", "tablet", "phone"]);
|
||||||
|
expect(panelParsers.device.parse("full")).toBe("full");
|
||||||
|
expect(panelParsers.device.defaultValue).toBe("tablet");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("panelParsers.sessions", () => {
|
||||||
|
it("parses ?sessions=1 style booleans and defaults to false", () => {
|
||||||
|
expect(panelParsers.sessions.parse("1")).toBe(true);
|
||||||
|
expect(panelParsers.sessions.defaultValue).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("panelParsers.step", () => {
|
||||||
|
it("covers the wizard steps with identity as default", () => {
|
||||||
|
expect([...WIZARD_STEPS]).toEqual(["identity", "access", "slack"]);
|
||||||
|
expect(panelParsers.step.defaultValue).toBe("identity");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAppView", () => {
|
||||||
|
it("maps the routines app id to the scheduled view (spec §7.6)", () => {
|
||||||
|
expect(resolveAppView("routines")).toBe("scheduled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes every other app id through unchanged", () => {
|
||||||
|
expect(resolveAppView("browser")).toBe("browser");
|
||||||
|
expect(resolveAppView("settings")).toBe("settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Typed parsers for every deep-linkable overlay state (spec §12).
|
||||||
|
//
|
||||||
|
// Single source of truth: components never read ?app/?device/?sessions/?step
|
||||||
|
// from raw searchParams. Path params are server-component territory; these
|
||||||
|
// search params are client-only shallow state — reading them in a server
|
||||||
|
// layout would force a server render on every panel toggle.
|
||||||
|
|
||||||
|
import {
|
||||||
|
createParser,
|
||||||
|
createSearchParamsCache,
|
||||||
|
parseAsStringLiteral,
|
||||||
|
} from "nuqs/server";
|
||||||
|
|
||||||
|
/// Boolean flag in the spec's `?sessions=1` shape (§6), also tolerating
|
||||||
|
/// `true` for hand-edited URLs.
|
||||||
|
const parseAsFlag = createParser<boolean>({
|
||||||
|
parse: (value) => value === "1" || value === "true",
|
||||||
|
serialize: (value) => (value ? "1" : "0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const APP_IDS = [
|
||||||
|
"browser",
|
||||||
|
"slack",
|
||||||
|
"chat",
|
||||||
|
"skills",
|
||||||
|
"files",
|
||||||
|
"routines",
|
||||||
|
"settings",
|
||||||
|
"apps",
|
||||||
|
] as const;
|
||||||
|
export type AppId = (typeof APP_IDS)[number];
|
||||||
|
|
||||||
|
export const DEVICE_SIZES = ["full", "tablet", "phone"] as const;
|
||||||
|
export type DeviceSize = (typeof DEVICE_SIZES)[number];
|
||||||
|
|
||||||
|
export const WIZARD_STEPS = ["identity", "access", "slack"] as const;
|
||||||
|
export type WizardStep = (typeof WIZARD_STEPS)[number];
|
||||||
|
|
||||||
|
export const panelParsers = {
|
||||||
|
app: parseAsStringLiteral(APP_IDS),
|
||||||
|
device: parseAsStringLiteral(DEVICE_SIZES).withDefault("tablet"),
|
||||||
|
sessions: parseAsFlag.withDefault(false),
|
||||||
|
step: parseAsStringLiteral(WIZARD_STEPS).withDefault("identity"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Server-side parse for deep-link first paint only (leaf pages, never
|
||||||
|
/// layouts).
|
||||||
|
export const panelParamsCache = createSearchParamsCache(panelParsers);
|
||||||
|
|
||||||
|
/// The `routines` app id renders the "scheduled" view internally (§7.6).
|
||||||
|
export function resolveAppView(app: AppId): AppId | "scheduled" {
|
||||||
|
return app === "routines" ? "scheduled" : app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodeSessionKeyParam,
|
||||||
|
encodeSessionKeyParam,
|
||||||
|
formatSessionKey,
|
||||||
|
parseSessionKey,
|
||||||
|
SessionKeyFormatError,
|
||||||
|
type SessionKey,
|
||||||
|
} from "./session-key";
|
||||||
|
|
||||||
|
const sample: SessionKey = {
|
||||||
|
agentId: "018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e",
|
||||||
|
shard: 3,
|
||||||
|
sessionId: "018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f",
|
||||||
|
messageId: "018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Must match the Rust codec in crates/tc-domain/src/session_key.rs exactly.
|
||||||
|
const sampleWire =
|
||||||
|
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3" +
|
||||||
|
":session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f" +
|
||||||
|
":018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a";
|
||||||
|
|
||||||
|
describe("formatSessionKey", () => {
|
||||||
|
it("produces the spec §12 layout", () => {
|
||||||
|
expect(formatSessionKey(sample)).toBe(sampleWire);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSessionKey", () => {
|
||||||
|
it("parses its own output", () => {
|
||||||
|
expect(parseSessionKey(formatSessionKey(sample))).toEqual(sample);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing agent prefix", () => {
|
||||||
|
expect(() => parseSessionKey(sampleWire.replace("agent:", "claw:"))).toThrow(
|
||||||
|
SessionKeyFormatError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing session segment", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseSessionKey(sampleWire.replace(":session:", ":chat:")),
|
||||||
|
).toThrow(SessionKeyFormatError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric shard", () => {
|
||||||
|
expect(() => parseSessionKey(sampleWire.replace("-claw-3", "-claw-x"))).toThrow(
|
||||||
|
SessionKeyFormatError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid uuids", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseSessionKey(
|
||||||
|
sampleWire.replace("018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e", "not-a-uuid"),
|
||||||
|
),
|
||||||
|
).toThrow(SessionKeyFormatError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects trailing segments", () => {
|
||||||
|
expect(() => parseSessionKey(`${sampleWire}:extra`)).toThrow(
|
||||||
|
SessionKeyFormatError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("URL param encoding", () => {
|
||||||
|
it("round-trips through percent-encoding", () => {
|
||||||
|
const param = encodeSessionKeyParam(sample);
|
||||||
|
// Colons must be encoded: proxies double-decode raw colons in paths.
|
||||||
|
expect(param).not.toContain(":");
|
||||||
|
expect(decodeSessionKeyParam(param)).toEqual(sample);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes keys that arrive already-decoded", () => {
|
||||||
|
// Some proxies decode the path before it reaches the app.
|
||||||
|
expect(decodeSessionKeyParam(sampleWire)).toEqual(sample);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// The deep-linkable chat session key from spec §12:
|
||||||
|
// `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`.
|
||||||
|
//
|
||||||
|
// This mirrors the Rust source of truth in
|
||||||
|
// crates/tc-domain/src/session_key.rs; a contract test keeps them aligned.
|
||||||
|
// All chat URL construction must go through this module — never hand-build
|
||||||
|
// session keys or their URL encoding.
|
||||||
|
|
||||||
|
export interface SessionKey {
|
||||||
|
agentId: string;
|
||||||
|
shard: number;
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionKeyFormatError extends Error {
|
||||||
|
constructor(input: string, reason: string) {
|
||||||
|
super(`malformed session key (${reason}): ${input}`);
|
||||||
|
this.name = "SessionKeyFormatError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function formatSessionKey(key: SessionKey): string {
|
||||||
|
return (
|
||||||
|
`agent:${key.agentId}-claw-${key.shard}` +
|
||||||
|
`:session:${key.sessionId}:${key.messageId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionKey(input: string): SessionKey {
|
||||||
|
if (!input.startsWith("agent:")) {
|
||||||
|
throw new SessionKeyFormatError(input, "missing agent prefix");
|
||||||
|
}
|
||||||
|
const rest = input.slice("agent:".length);
|
||||||
|
const sessionIdx = rest.indexOf(":session:");
|
||||||
|
if (sessionIdx === -1) {
|
||||||
|
throw new SessionKeyFormatError(input, "missing session segment");
|
||||||
|
}
|
||||||
|
const agentPart = rest.slice(0, sessionIdx);
|
||||||
|
const tail = rest.slice(sessionIdx + ":session:".length);
|
||||||
|
|
||||||
|
// "-claw-" cannot occur inside a UUID (hex and dashes only), so the
|
||||||
|
// rightmost occurrence cleanly separates id from shard.
|
||||||
|
const clawIdx = agentPart.lastIndexOf("-claw-");
|
||||||
|
if (clawIdx === -1) {
|
||||||
|
throw new SessionKeyFormatError(input, "missing claw shard");
|
||||||
|
}
|
||||||
|
const agentId = agentPart.slice(0, clawIdx);
|
||||||
|
const shardStr = agentPart.slice(clawIdx + "-claw-".length);
|
||||||
|
if (!/^\d+$/.test(shardStr)) {
|
||||||
|
throw new SessionKeyFormatError(input, "shard is not a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailParts = tail.split(":");
|
||||||
|
if (tailParts.length !== 2) {
|
||||||
|
throw new SessionKeyFormatError(input, "wrong number of segments");
|
||||||
|
}
|
||||||
|
const [sessionId, messageId] = tailParts;
|
||||||
|
|
||||||
|
for (const [name, value] of [
|
||||||
|
["agentId", agentId],
|
||||||
|
["sessionId", sessionId],
|
||||||
|
["messageId", messageId],
|
||||||
|
] as const) {
|
||||||
|
if (!UUID_RE.test(value)) {
|
||||||
|
throw new SessionKeyFormatError(input, `${name} is not a uuid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { agentId, shard: Number(shardStr), sessionId, messageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent-encodes a key for use as a path segment. Raw colons in paths get
|
||||||
|
/// double-decoded by some proxies, so they are always encoded.
|
||||||
|
export function encodeSessionKeyParam(key: SessionKey | string): string {
|
||||||
|
const wire = typeof key === "string" ? key : formatSessionKey(key);
|
||||||
|
return encodeURIComponent(wire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a path segment back into a key, tolerating params that arrive
|
||||||
|
/// already decoded (proxies differ on this).
|
||||||
|
export function decodeSessionKeyParam(param: string): SessionKey {
|
||||||
|
const wire = param.includes("%") ? decodeURIComponent(param) : param;
|
||||||
|
return parseSessionKey(wire);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/* Motion system, spec §3. Slide-out panels animate WIDTH (they push layout,
|
||||||
|
never overlay); the keyframe inventory below is the complete spec set. */
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes sheet-in {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes slide-up-in {
|
||||||
|
from { transform: translateY(12px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { transform: scale(0.96); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes fade-out {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes fade-up {
|
||||||
|
from { transform: translateY(6px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes collapsible-expand {
|
||||||
|
from { grid-template-rows: 0fr; }
|
||||||
|
to { grid-template-rows: 1fr; }
|
||||||
|
}
|
||||||
|
@keyframes collapsible-collapse {
|
||||||
|
from { grid-template-rows: 1fr; }
|
||||||
|
to { grid-template-rows: 0fr; }
|
||||||
|
}
|
||||||
|
@keyframes route-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: -200% 0; }
|
||||||
|
to { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
@keyframes ripple {
|
||||||
|
from { transform: scale(0); opacity: 0.35; }
|
||||||
|
to { transform: scale(2.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
@keyframes caret-blink {
|
||||||
|
0%, 45% { opacity: 1; }
|
||||||
|
50%, 95% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20%, 60% { transform: translateX(-4px); }
|
||||||
|
40%, 80% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes ping {
|
||||||
|
75%, 100% { transform: scale(2); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
@keyframes toolSlideIn {
|
||||||
|
from { transform: translateX(-8px); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes toolAccentPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 45%, transparent); }
|
||||||
|
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 12%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provisioning (new-agent spin-up), spec §3. */
|
||||||
|
@keyframes avatar-breathe {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.04); }
|
||||||
|
}
|
||||||
|
@keyframes photo-rotate {
|
||||||
|
from { transform: rotate(-2deg); }
|
||||||
|
to { transform: rotate(2deg); }
|
||||||
|
}
|
||||||
|
@keyframes photo-bob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
@keyframes tip-slide {
|
||||||
|
from { transform: translateY(8px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes tip-fade {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The width-animation pattern shared by every slide-out (§3): the track
|
||||||
|
animates between 0 and a fixed target width while the inner content keeps
|
||||||
|
its final width, so children never reflow mid-animation. */
|
||||||
|
.panel-track {
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
width var(--duration-normal) var(--ease-app),
|
||||||
|
border-color var(--duration-slow) var(--ease-out-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": new URL("./src", import.meta.url).pathname },
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user