R1+R2: shell and chat restyled to the reference system
Shell (main-chat-shell-spec): rail rebuilt around a 48px squircle claw avatar stack with a 2px coral active ring + name beneath + online dot, dashed 'New claw' tile, lucide-icon nav pills with the live credits balance shown inline (coral when negative, aria-hidden so the nav link name stays 'Credits'); 80px transparent chat header with 36px round sessions/new-session icon buttons and the 42px coral-glow Computer launcher; 768px centered content rail. RosterList computes the active claw from the pathname so AgentRosterItem stays presentational. Chat (chat-message-components): asymmetric layout — user pill #1A1A1A radius 24/24/4 with inset white ring + dual shadow, capped 75%, vs the bubble-less assistant message (50px squircle avatar, 15px gap, plain 14px/1.7 text); messageSlideIn entrance; 126px welcome avatar with the 24px/600/-0.6px heading (claw name in coral) and lucide-led suggestion chips; floating 24px-radius neutral-800 composer card with the cream Send pill; StepTrace rows restyled to the system. Brand marks (Chrome, Slack) vendored to public/services from the bundle. 83 unit tests, full 29-journey functional E2E green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3ec1d3b829
commit
6e061515b8
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48" height="48" width="48"><defs><linearGradient id="a" x1="3.2173" y1="15" x2="44.7812" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d93025"/><stop offset="1" stop-color="#ea4335"/></linearGradient><linearGradient id="b" x1="20.7219" y1="47.6791" x2="41.5039" y2="11.6837" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcc934"/><stop offset="1" stop-color="#fbbc04"/></linearGradient><linearGradient id="c" x1="26.5981" y1="46.5015" x2="5.8161" y2="10.506" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1e8e3e"/><stop offset="1" stop-color="#34a853"/></linearGradient></defs><circle cx="24" cy="23.9947" r="12" style="fill:#fff"/><path d="M3.2154,36A24,24,0,1,0,12,3.2154,24,24,0,0,0,3.2154,36ZM34.3923,18A12,12,0,1,1,18,13.6077,12,12,0,0,1,34.3923,18Z" style="fill:none"/><path d="M24,12H44.7812a23.9939,23.9939,0,0,0-41.5639.0029L13.6079,30l.0093-.0024A11.9852,11.9852,0,0,1,24,12Z" style="fill:url(#a)"/><circle cx="24" cy="24" r="9.5" style="fill:#1a73e8"/><path d="M34.3913,30.0029,24.0007,48A23.994,23.994,0,0,0,44.78,12.0031H23.9989l-.0025.0093A11.985,11.985,0,0,1,34.3913,30.0029Z" style="fill:url(#b)"/><path d="M13.6086,30.0031,3.218,12.006A23.994,23.994,0,0,0,24.0025,48L34.3931,30.0029l-.0067-.0068a11.9852,11.9852,0,0,1-20.7778.007Z" style="fill:url(#c)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="127" height="127" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z" fill="#E01E5A"/>
|
||||||
|
<path d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z" fill="#36C5F0"/>
|
||||||
|
<path d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z" fill="#2EB67D"/>
|
||||||
|
<path d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z" fill="#ECB22E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1019 B |
@@ -2,8 +2,8 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { LeftRail } from "@/components/shell/LeftRail";
|
import { LeftRail } from "@/components/shell/LeftRail";
|
||||||
import { ApiAuthError } from "@/lib/api/http";
|
import { ApiAuthError } from "@/lib/api/http";
|
||||||
import { fetchClaws, fetchMe } from "@/lib/api/team";
|
import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
|
||||||
import type { Agent, User } from "@/lib/api/schemas";
|
import type { Agent, Credits, User } from "@/lib/api/schemas";
|
||||||
|
|
||||||
// Every workspace page shares the persistent rail (§4). This layout reads
|
// Every workspace page shares the persistent rail (§4). This layout reads
|
||||||
// only path-level data — search params stay client-side (see panel-params).
|
// only path-level data — search params stay client-side (see panel-params).
|
||||||
@@ -12,8 +12,13 @@ export default async function WorkspaceLayout({
|
|||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
let user: User;
|
let user: User;
|
||||||
let roster: Agent[];
|
let roster: Agent[];
|
||||||
|
let credits: Credits;
|
||||||
try {
|
try {
|
||||||
[user, roster] = await Promise.all([fetchMe(), fetchClaws()]);
|
[user, roster, credits] = await Promise.all([
|
||||||
|
fetchMe(),
|
||||||
|
fetchClaws(),
|
||||||
|
fetchCredits(),
|
||||||
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiAuthError) {
|
if (error instanceof ApiAuthError) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
@@ -22,7 +27,11 @@ export default async function WorkspaceLayout({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh">
|
<div className="flex min-h-dvh">
|
||||||
<LeftRail user={user} roster={roster} />
|
<LeftRail
|
||||||
|
user={user}
|
||||||
|
roster={roster}
|
||||||
|
creditsBalance={credits.available}
|
||||||
|
/>
|
||||||
<main className="min-w-0 flex-1">{children}</main>
|
<main className="min-w-0 flex-1">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { List, Monitor, SquarePen } from "lucide-react";
|
||||||
import { useQueryStates } from "nuqs";
|
import { useQueryStates } from "nuqs";
|
||||||
|
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
import { panelParsers } from "@/lib/url/panel-params";
|
import { panelParsers } from "@/lib/url/panel-params";
|
||||||
|
|
||||||
/** Chat header (§5b): identity left, sessions/new-session controls right. */
|
/** Chat header (measured): 80px, transparent, identity left; sessions /
|
||||||
|
* new-session as 36px round icon buttons; the 42px coral-glow Computer
|
||||||
|
* launcher on the right. Accessible names unchanged from §5b. */
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
agent,
|
agent,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
@@ -18,8 +21,14 @@ export function ChatHeader({
|
|||||||
shallow: true,
|
shallow: true,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center gap-3 border-b border-border px-4">
|
<header className="flex h-20 items-center gap-3 pr-6 pl-5">
|
||||||
<Avatar name={agent.name} accent={agent.accent} size="sm" />
|
<Avatar
|
||||||
|
name={agent.name}
|
||||||
|
accent={agent.accent}
|
||||||
|
size="md"
|
||||||
|
shape="squircle"
|
||||||
|
online={agent.status === "online"}
|
||||||
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{agent.name}</p>
|
<p className="truncate text-sm font-medium">{agent.name}</p>
|
||||||
<p className="truncate text-xxs text-muted-foreground">
|
<p className="truncate text-xxs text-muted-foreground">
|
||||||
@@ -28,25 +37,32 @@ export function ChatHeader({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setParams({ app: "home" })}
|
aria-label="Sessions"
|
||||||
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Computer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={sessions}
|
aria-pressed={sessions}
|
||||||
onClick={() => setParams({ sessions: !sessions })}
|
onClick={() => setParams({ sessions: !sessions })}
|
||||||
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
className={`inline-flex size-9 items-center justify-center rounded-radius-button transition-colors duration-normal ease-app ${
|
||||||
|
sessions
|
||||||
|
? "bg-surface-warm-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-hover-bg hover:text-foreground"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Sessions
|
<List aria-hidden size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="New"
|
||||||
onClick={onNewSession}
|
onClick={onNewSession}
|
||||||
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
className="inline-flex size-9 items-center justify-center rounded-radius-button text-muted-foreground transition-colors duration-normal ease-app hover:bg-hover-bg hover:text-foreground"
|
||||||
>
|
>
|
||||||
New
|
<SquarePen aria-hidden size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Computer"
|
||||||
|
onClick={() => setParams({ app: "home" })}
|
||||||
|
className="inline-flex size-[42px] items-center justify-center rounded-radius-button text-coral shadow-launcher transition-all duration-normal ease-app hover:bg-coral/10 active:scale-95"
|
||||||
|
>
|
||||||
|
<Monitor aria-hidden size={20} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,10 +72,15 @@ export function ChatWorkspace({
|
|||||||
</SlidePanel>
|
</SlidePanel>
|
||||||
<section className="flex min-w-0 flex-1 flex-col">
|
<section className="flex min-w-0 flex-1 flex-col">
|
||||||
<ChatHeader agent={agent} onNewSession={newSession} />
|
<ChatHeader agent={agent} onNewSession={newSession} />
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl min-w-0 flex-1 flex-col overflow-hidden px-4">
|
||||||
{state.messages.length === 0 ? (
|
{state.messages.length === 0 ? (
|
||||||
<WelcomeState agent={agent} onPick={setDraft} />
|
<WelcomeState agent={agent} onPick={setDraft} />
|
||||||
) : (
|
) : (
|
||||||
<MessageList messages={state.messages} agent={agent} onDecide={decide} />
|
<MessageList
|
||||||
|
messages={state.messages}
|
||||||
|
agent={agent}
|
||||||
|
onDecide={decide}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Composer
|
<Composer
|
||||||
agentName={agent.name}
|
agentName={agent.name}
|
||||||
@@ -84,6 +89,7 @@ export function ChatWorkspace({
|
|||||||
draft={draft}
|
draft={draft}
|
||||||
onDraftChange={setDraft}
|
onDraftChange={setDraft}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<DevicePanel agent={agent} />
|
<DevicePanel agent={agent} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ interface ComposerProps {
|
|||||||
onDraftChange?: (value: string) => void;
|
onDraftChange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chat composer (§5): Enter sends, Shift+Enter inserts a newline. */
|
/** The chat composer (measured): a floating 24px-radius neutral-800 card
|
||||||
|
* with an inset hairline ring and soft lift, cream send pill. Enter
|
||||||
|
* sends, Shift+Enter inserts a newline. */
|
||||||
export function Composer({
|
export function Composer({
|
||||||
agentName,
|
agentName,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -41,10 +43,8 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={submit} className="pb-4">
|
||||||
onSubmit={submit}
|
<div className="flex items-end gap-2 rounded-[24px] bg-surface-warm-muted px-4 py-3 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_-4px_rgba(0,0,0,0.06),inset_0_0_0_1px_rgba(255,255,255,0.063)]">
|
||||||
className="flex items-end gap-2 border-t border-border bg-background px-4 py-3"
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
aria-label={`Message ${agentName}`}
|
aria-label={`Message ${agentName}`}
|
||||||
placeholder={`Message ${agentName}...`}
|
placeholder={`Message ${agentName}...`}
|
||||||
@@ -52,15 +52,16 @@ export function Composer({
|
|||||||
rows={1}
|
rows={1}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="max-h-40 min-h-10 flex-1 resize-y rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
|
className="max-h-40 min-h-8 flex-1 resize-none bg-transparent py-1 text-base text-foreground outline-none placeholder:text-subtle-foreground"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled || value.trim() === ""}
|
disabled={disabled || value.trim() === ""}
|
||||||
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-button) hover:bg-coral-light disabled:opacity-40"
|
className="rounded-radius-button bg-cream px-4 py-2 text-sm font-medium text-primary-foreground shadow-cta transition-colors duration-normal ease-app hover:bg-white disabled:opacity-40 disabled:shadow-none"
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { StepTrace } from "./steps/StepTrace";
|
|||||||
|
|
||||||
type DecideFn = (approvalId: string, decision: "approve" | "reject") => void;
|
type DecideFn = (approvalId: string, decision: "approve" | "reject") => void;
|
||||||
|
|
||||||
|
/* User pill (measured): #1A1A1A fill, radius 24/24/4 (the tucked tail),
|
||||||
|
1px inset white ring, dual shadow, capped at 75%, right-aligned. */
|
||||||
function UserBubble({ message }: { message: UiMessage }) {
|
function UserBubble({ message }: { message: UiMessage }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex w-full justify-end motion-safe:animate-[messageSlideIn_var(--duration-normal)_var(--ease-app)]">
|
||||||
<p
|
<p
|
||||||
className={`max-w-[70%] whitespace-pre-wrap rounded-(--radius) bg-surface-warm-muted px-3 py-2 text-sm shadow-(--shadow-bubble) ${
|
className={`w-fit max-w-full rounded-[24px] rounded-br-[4px] bg-bubble-user px-3.5 py-2.5 text-sm leading-[1.7] whitespace-pre-wrap text-bubble-user-text shadow-bubble-user ring-1 ring-white/[0.07] ring-inset md:max-w-[75%] ${
|
||||||
message.status === "sending" ? "opacity-60" : ""
|
message.status === "sending" ? "opacity-60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -24,6 +26,8 @@ function UserBubble({ message }: { message: UiMessage }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Assistant message (measured): NO bubble — 50px avatar, 15px gap, plain
|
||||||
|
text flowing directly. */
|
||||||
function AgentMessage({
|
function AgentMessage({
|
||||||
message,
|
message,
|
||||||
agent,
|
agent,
|
||||||
@@ -34,10 +38,15 @@ function AgentMessage({
|
|||||||
onDecide: DecideFn;
|
onDecide: DecideFn;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex items-start gap-[15px] motion-safe:animate-[messageSlideIn_var(--duration-normal)_var(--ease-app)]">
|
||||||
<Avatar name={agent.name} accent={agent.accent} size="sm" />
|
<Avatar
|
||||||
<div className="min-w-0 flex-1">
|
name={agent.name}
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
accent={agent.accent}
|
||||||
|
size="chat"
|
||||||
|
shape="squircle"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1 pt-3">
|
||||||
|
<p className="text-sm leading-[1.7] whitespace-pre-wrap text-neutral-200">
|
||||||
{message.text}
|
{message.text}
|
||||||
{message.status === "streaming" && (
|
{message.status === "streaming" && (
|
||||||
<span
|
<span
|
||||||
@@ -64,7 +73,7 @@ function AgentMessage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The transcript (§5b): user bubbles right, agent messages left. */
|
/** The transcript (measured): user pills right, bare assistant text left. */
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
agent,
|
agent,
|
||||||
@@ -84,7 +93,7 @@ export function MessageList({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-6"
|
className="flex flex-1 flex-col gap-2 overflow-y-auto py-6"
|
||||||
>
|
>
|
||||||
{messages.map((message) =>
|
{messages.map((message) =>
|
||||||
message.role === "user" ? (
|
message.role === "user" ? (
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import { FileText, Presentation, Sparkles, Sun } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
|
||||||
const SUGGESTED_PROMPTS = [
|
const SUGGESTED_PROMPTS: { label: string; icon: LucideIcon }[] = [
|
||||||
"Create a daily briefing",
|
{ label: "Create a daily briefing", icon: Sun },
|
||||||
"Write a report",
|
{ label: "Write a report", icon: FileText },
|
||||||
"Make a presentation",
|
{ label: "Make a presentation", icon: Presentation },
|
||||||
"What can you do?",
|
{ label: "What can you do?", icon: Sparkles },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Empty-session welcome (§5a). */
|
/** Empty-session welcome (measured): 126px avatar, 24px/600/-0.6px heading
|
||||||
|
* with the claw's name in coral, subline, suggestion chips. */
|
||||||
export function WelcomeState({
|
export function WelcomeState({
|
||||||
agent,
|
agent,
|
||||||
onPick,
|
onPick,
|
||||||
@@ -18,22 +22,29 @@ export function WelcomeState({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
|
||||||
<Avatar name={agent.name} accent={agent.accent} size="lg" />
|
<Avatar
|
||||||
<h2 className="text-center text-xl font-semibold tracking-tight">
|
name={agent.name}
|
||||||
Hi, I'm {agent.name}. What can I help with?
|
accent={agent.accent}
|
||||||
|
size="welcome"
|
||||||
|
shape="squircle"
|
||||||
|
/>
|
||||||
|
<h2 className="text-center text-2xl leading-[33px] font-semibold tracking-[-0.6px]">
|
||||||
|
Hi, I'm <span className="text-coral">{agent.name}</span>. What can
|
||||||
|
I help with?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
{agent.job_title} · Shared with your team
|
{agent.job_title} · Shared with your team
|
||||||
</p>
|
</p>
|
||||||
<div className="flex max-w-md flex-wrap justify-center gap-2 pt-2">
|
<div className="flex max-w-xl flex-wrap justify-center gap-2 pt-2">
|
||||||
{SUGGESTED_PROMPTS.map((prompt) => (
|
{SUGGESTED_PROMPTS.map(({ label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={prompt}
|
key={label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPick(prompt)}
|
onClick={() => onPick(label)}
|
||||||
className="rounded-(--radius-button) border border-border bg-subtle px-3 py-1.5 text-xs text-muted-foreground hover:border-accent hover:text-foreground"
|
className="inline-flex h-9 items-center gap-2 rounded-radius-button bg-surface-warm-muted px-3.5 text-xs font-medium text-neutral-200 shadow-bubble ring-1 ring-neutral-700/70 transition-colors duration-normal ease-app ring-inset hover:bg-neutral-700/60 hover:ring-neutral-600"
|
||||||
>
|
>
|
||||||
{prompt}
|
<Icon aria-hidden size={14} className="shrink-0 text-coral" />
|
||||||
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function StepTrace({ steps }: { steps: UiStep[] }) {
|
|||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-controls={regionId}
|
aria-controls={regionId}
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
className="rounded-(--radius) px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-surface-warm hover:text-foreground"
|
className="rounded-radius-button px-2 py-0.5 text-xs text-muted-foreground transition-colors duration-normal ease-app hover:bg-hover-bg hover:text-foreground"
|
||||||
>
|
>
|
||||||
{expanded ? "▾" : "▸"} {steps.length}{" "}
|
{expanded ? "▾" : "▸"} {steps.length}{" "}
|
||||||
{steps.length === 1 ? "step" : "steps"}
|
{steps.length === 1 ? "step" : "steps"}
|
||||||
@@ -28,7 +28,7 @@ export function StepTrace({ steps }: { steps: UiStep[] }) {
|
|||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
<li
|
<li
|
||||||
key={step.seq}
|
key={step.seq}
|
||||||
className="rounded-(--radius) border border-border bg-subtle px-2 py-1.5 font-mono text-xs motion-safe:animate-[toolSlideIn_var(--duration-fast)_var(--ease-app)]"
|
className="rounded-xl bg-surface-warm px-2.5 py-1.5 font-mono text-xs ring-1 ring-white/[0.05] ring-inset motion-safe:animate-[toolSlideIn_var(--duration-fast)_var(--ease-app)]"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import type { CSSProperties, ReactNode } from "react";
|
|||||||
|
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
|
||||||
/** Derives the per-agent panel theme (§7: "themed per-agent"). Art assets
|
/** Derives the per-agent panel theme (measured). The base wallpaper is a
|
||||||
* are excluded by spec, so wallpapers are accent-derived gradients. */
|
* 135° gradient like the reference (slate/navy by default, tinted toward
|
||||||
|
* the agent's accent); art assets are excluded by spec. */
|
||||||
export function clawThemeStyle(agent: Agent): CSSProperties {
|
export function clawThemeStyle(agent: Agent): CSSProperties {
|
||||||
const accent = agent.accent || "#f96565";
|
const accent = agent.accent || "#f96565";
|
||||||
return {
|
return {
|
||||||
"--agent-accent": accent,
|
"--agent-accent": accent,
|
||||||
"--agent-wallpaper": `linear-gradient(160deg, color-mix(in srgb, ${accent} 24%, #0a0a0a) 0%, #101010 55%, color-mix(in srgb, ${accent} 10%, #0a0a0a) 100%)`,
|
"--agent-wallpaper": `linear-gradient(135deg, color-mix(in srgb, ${accent} 20%, #0f172a) 0%, #1e293b 100%)`,
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +26,24 @@ export function GrainOverlay() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The measured wallpaper stack, back→front: base gradient, a #000/.40
|
||||||
|
* vignette, and a frosted glass overlay (blur 40px over white/5). Grain
|
||||||
|
* rides on top; children sit above it all. */
|
||||||
export function WallpaperSurface({ children }: { children: ReactNode }) {
|
export function WallpaperSurface({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
<div className="relative flex h-full flex-col overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full flex-col"
|
aria-hidden
|
||||||
|
className="absolute inset-0 -z-20"
|
||||||
style={{ background: "var(--agent-wallpaper)" }}
|
style={{ background: "var(--agent-wallpaper)" }}
|
||||||
>
|
/>
|
||||||
|
<div aria-hidden className="absolute inset-0 -z-10 bg-black/40" />
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 bg-white/5 opacity-[0.36] backdrop-blur-[40px]"
|
||||||
|
/>
|
||||||
<GrainOverlay />
|
<GrainOverlay />
|
||||||
{children}
|
<div className="relative z-0 flex h-full flex-col">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Folder,
|
||||||
|
Globe,
|
||||||
|
MessageCircle,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
Slack,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import type { AppId } from "@/lib/url/panel-params";
|
||||||
|
|
||||||
|
/** The lucide glyph per panel app (measured icon manifest). Browser/Slack
|
||||||
|
* use their brand marks on the home tiles; everything else is a
|
||||||
|
* coral-gradient lucide glyph. */
|
||||||
|
export const APP_ICON: Record<string, LucideIcon> = {
|
||||||
|
browser: Globe,
|
||||||
|
slack: Slack,
|
||||||
|
chat: MessageCircle,
|
||||||
|
apps: Plus,
|
||||||
|
skills: Zap,
|
||||||
|
files: Folder,
|
||||||
|
routines: Bell,
|
||||||
|
scheduled: Bell,
|
||||||
|
settings: Settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HOME_GRID: { app: AppId; label: string }[] = [
|
||||||
|
{ app: "browser", label: "Browser" },
|
||||||
|
{ app: "slack", label: "Slack" },
|
||||||
|
{ app: "chat", label: "Claw Chat" },
|
||||||
|
{ app: "apps", label: "Add" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HOME_DOCK: { app: AppId; label: string }[] = [
|
||||||
|
{ app: "skills", label: "Skills" },
|
||||||
|
{ app: "files", label: "Files" },
|
||||||
|
{ app: "routines", label: "Routines" },
|
||||||
|
{ app: "settings", label: "Settings" },
|
||||||
|
];
|
||||||
@@ -3,23 +3,51 @@ import Link from "next/link";
|
|||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
|
||||||
/** One agent row in the left rail (§4): avatar, name, online dot. Links to
|
/** One claw in the rail stack (measured): 48px squircle avatar with a 2px
|
||||||
* the claw's chat (latest session resumes, or a fresh one opens). */
|
* coral ring when its chat is open, name beneath, online dot. */
|
||||||
export function AgentRosterItem({ agent }: { agent: Agent }) {
|
export function AgentRosterItem({
|
||||||
|
agent,
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
agent: Agent;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/claws/${agent.id}`}
|
href={`/claws/${agent.id}`}
|
||||||
className="flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-foreground hover:bg-surface-warm"
|
aria-current={active ? "page" : undefined}
|
||||||
|
className="group flex w-full flex-col items-center gap-1 py-1.5"
|
||||||
>
|
>
|
||||||
<Avatar name={agent.name} accent={agent.accent} size="sm" />
|
<span
|
||||||
<span className="truncate">{agent.name}</span>
|
className={`rounded-[19px] p-0.5 transition-colors duration-normal ease-app ${
|
||||||
|
active ? "ring-2 ring-coral" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<Avatar
|
||||||
|
name={agent.name}
|
||||||
|
accent={agent.accent}
|
||||||
|
size="rail"
|
||||||
|
shape="squircle"
|
||||||
|
/>
|
||||||
{agent.status === "online" && (
|
{agent.status === "online" && (
|
||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={`${agent.name} is online`}
|
aria-label={`${agent.name} is online`}
|
||||||
className="ml-auto size-1.5 shrink-0 rounded-full bg-green-500"
|
className="absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-background bg-green-500"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`max-w-full truncate text-xs transition-colors duration-normal ease-app ${
|
||||||
|
active
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground group-hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { GlobalNavItem } from "./GlobalNavItem";
|
import { GlobalNavItem } from "./GlobalNavItem";
|
||||||
|
|
||||||
describe("GlobalNavItem", () => {
|
describe("GlobalNavItem", () => {
|
||||||
it("marks the active item with aria-current and the coral accent", () => {
|
it("marks the active item with aria-current, bright text, coral icon", () => {
|
||||||
render(<GlobalNavItem href="/team" label="Team" active />);
|
const { container } = render(
|
||||||
|
<GlobalNavItem href="/team" label="Team" icon={Users} active />,
|
||||||
|
);
|
||||||
const link = screen.getByRole("link", { name: "Team" });
|
const link = screen.getByRole("link", { name: "Team" });
|
||||||
expect(link).toHaveAttribute("aria-current", "page");
|
expect(link).toHaveAttribute("aria-current", "page");
|
||||||
expect(link.className).toContain("text-accent");
|
expect(link.className).toContain("text-foreground");
|
||||||
|
expect(container.querySelector("svg")!.getAttribute("class")).toContain(
|
||||||
|
"text-coral",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders inactive items muted without aria-current", () => {
|
it("renders inactive items muted without aria-current", () => {
|
||||||
render(<GlobalNavItem href="/credits" label="Credits" active={false} />);
|
render(
|
||||||
|
<GlobalNavItem
|
||||||
|
href="/credits"
|
||||||
|
label="Credits"
|
||||||
|
icon={Users}
|
||||||
|
active={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
const link = screen.getByRole("link", { name: "Credits" });
|
const link = screen.getByRole("link", { name: "Credits" });
|
||||||
expect(link).not.toHaveAttribute("aria-current");
|
expect(link).not.toHaveAttribute("aria-current");
|
||||||
expect(link.className).toContain("text-muted-foreground");
|
expect(link.className).toContain("text-muted-foreground");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows trailing content like the credits balance", () => {
|
||||||
|
render(
|
||||||
|
<GlobalNavItem
|
||||||
|
href="/credits"
|
||||||
|
label="Credits"
|
||||||
|
icon={Users}
|
||||||
|
active={false}
|
||||||
|
trailing={<span>1,250</span>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("1,250")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,47 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
interface GlobalNavItemProps {
|
interface GlobalNavItemProps {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
/** Computed by the shell from the current pathname; active uses coral (§4). */
|
/** Computed by the shell from the current pathname; active uses coral (§4). */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** Inline trailing content (e.g. the credits balance). */
|
||||||
|
trailing?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GlobalNavItem({ href, label, active }: GlobalNavItemProps) {
|
/* Rail nav pill (measured): h-32px, icon+label gap 12px, full radius,
|
||||||
|
hover #171717; the active route brightens and its icon goes coral. */
|
||||||
|
export function GlobalNavItem({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
active,
|
||||||
|
trailing,
|
||||||
|
}: GlobalNavItemProps) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={`block rounded-(--radius) px-2 py-1.5 text-sm transition-colors duration-(--duration-fast) ${
|
className={`flex h-8 items-center gap-3 rounded-radius-button px-3 text-base transition-colors duration-normal ease-app ${
|
||||||
active
|
active
|
||||||
? "text-accent"
|
? "text-foreground"
|
||||||
: "text-muted-foreground hover:bg-surface-warm hover:text-foreground"
|
: "text-muted-foreground hover:bg-hover-bg hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
<Icon
|
||||||
|
aria-hidden
|
||||||
|
size={16}
|
||||||
|
className={`shrink-0 ${active ? "text-coral" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{trailing != null && (
|
||||||
|
<span aria-hidden className="ml-auto text-sm">
|
||||||
|
{trailing}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
import type { Agent, User } from "@/lib/api/schemas";
|
import type { Agent, User } from "@/lib/api/schemas";
|
||||||
import { AgentRosterItem } from "./AgentRosterItem";
|
import { AgentRosterItem } from "./AgentRosterItem";
|
||||||
@@ -8,21 +9,23 @@ import { UserMenu } from "./UserMenu";
|
|||||||
interface LeftRailProps {
|
interface LeftRailProps {
|
||||||
user: User;
|
user: User;
|
||||||
roster: Agent[];
|
roster: Agent[];
|
||||||
|
creditsBalance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The persistent 176px left rail (§4): logo, agent roster, global nav,
|
/** The persistent 176px left rail (measured): wordmark, the claw avatar
|
||||||
* current user pinned to the bottom. */
|
* stack (48px squircles), dashed add tile, then global nav + the current
|
||||||
export function LeftRail({ user, roster }: LeftRailProps) {
|
* user pinned to the bottom. */
|
||||||
|
export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-dvh w-rail shrink-0 flex-col border-r border-border bg-subtle px-2 py-4">
|
<aside className="flex h-dvh w-rail shrink-0 flex-col px-1 py-4">
|
||||||
<p className="px-2 pb-4 font-mono text-sm font-semibold tracking-tight text-foreground">
|
<p className="px-3 pb-4 font-mono text-sm font-semibold tracking-tight text-foreground">
|
||||||
clawmates
|
clawmates
|
||||||
</p>
|
</p>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{roster.length === 0 ? (
|
{roster.length === 0 ? (
|
||||||
<p className="px-2 text-xs text-muted-foreground">No claws yet</p>
|
<p className="px-3 text-xs text-muted-foreground">No claws yet</p>
|
||||||
) : (
|
) : (
|
||||||
<ul aria-label="Your claws" className="flex flex-col gap-0.5">
|
<ul aria-label="Your claws" className="flex flex-col">
|
||||||
{roster.map((agent) => (
|
{roster.map((agent) => (
|
||||||
<li key={agent.id}>
|
<li key={agent.id}>
|
||||||
<AgentRosterItem agent={agent} />
|
<AgentRosterItem agent={agent} />
|
||||||
@@ -32,19 +35,21 @@ export function LeftRail({ user, roster }: LeftRailProps) {
|
|||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/claws/new"
|
href="/claws/new"
|
||||||
className="mt-1 flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-muted-foreground hover:bg-surface-warm hover:text-foreground"
|
className="group mt-1 flex w-full flex-col items-center gap-1 py-1.5"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="inline-flex size-6 items-center justify-center rounded-full border border-dashed border-border"
|
className="inline-flex size-12 items-center justify-center rounded-radius-squircle border border-dashed border-border text-coral transition-colors duration-normal ease-app group-hover:border-coral"
|
||||||
>
|
>
|
||||||
+
|
<Plus size={20} />
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground transition-colors duration-normal ease-app group-hover:text-foreground">
|
||||||
New claw
|
New claw
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 pt-3">
|
<div className="flex flex-col gap-3 px-1 pt-3">
|
||||||
<ShellNav />
|
<ShellNav creditsBalance={creditsBalance} />
|
||||||
<UserMenu user={user} />
|
<UserMenu user={user} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { AgentRosterItem } from "./AgentRosterItem";
|
||||||
|
|
||||||
|
/** Client wrapper computing which claw's chat is open from the pathname
|
||||||
|
* (mirrors ShellNav) so the roster item itself stays presentational. */
|
||||||
|
export function RosterList({ roster }: { roster: Agent[] }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<ul aria-label="Your claws" className="flex flex-col">
|
||||||
|
{roster.map((agent) => (
|
||||||
|
<li key={agent.id}>
|
||||||
|
<AgentRosterItem
|
||||||
|
agent={agent}
|
||||||
|
active={pathname.startsWith(`/claws/${agent.id}`)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { CreditCard, ShieldCheck, Users, Zap } from "lucide-react";
|
||||||
|
|
||||||
import { GlobalNavItem } from "./GlobalNavItem";
|
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 = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/skills", label: "Skills" },
|
{ href: "/skills", label: "Skills", icon: Zap },
|
||||||
{ href: "/approvals", label: "Approvals" },
|
{ href: "/approvals", label: "Approvals", icon: ShieldCheck },
|
||||||
{ href: "/team", label: "Team" },
|
{ href: "/team", label: "Team", icon: Users },
|
||||||
{ href: "/credits", label: "Credits" },
|
{ href: "/credits", label: "Credits", icon: CreditCard },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ShellNav() {
|
/** Global nav pinned under the roster; Credits shows the live balance
|
||||||
|
* inline (coral when negative, per the measured rail spec). */
|
||||||
|
export function ShellNav({ creditsBalance }: { creditsBalance?: number }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Global" className="flex flex-col gap-0.5">
|
<nav aria-label="Global" className="flex flex-col gap-0.5">
|
||||||
@@ -22,7 +23,21 @@ export function ShellNav() {
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
active={pathname === item.href || pathname.startsWith(`${item.href}/`)}
|
icon={item.icon}
|
||||||
|
active={
|
||||||
|
pathname === item.href || pathname.startsWith(`${item.href}/`)
|
||||||
|
}
|
||||||
|
trailing={
|
||||||
|
item.href === "/credits" && creditsBalance !== undefined ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
creditsBalance < 0 ? "text-coral" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creditsBalance.toLocaleString("en-US")}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user