Skip to content

Commit ef4ebe4

Browse files
IamRamgarhiaclaude
andcommitted
Active client visible everywhere · sidebar quick-switch · scoped recents · brain completeness
Your ask: when a client is selected, every option in the sidebar should work according to that same client. Tools already auto-read the active brain (smart-fill + cross-client switch already wired), but a few surfaces never advertised which client was actually active and a few others showed cross-client data when they shouldn't. Fixed across the board. Sidebar — persistent active-client block + quick-switch - New block sits between the AdForge brand header and the nav list, visible on every page. - When a client is active: orange brain icon + "ACTIVE CLIENT" caption + client name. Click expands a dropdown listbox of every saved client with the live one checkmarked. Picking from the list calls setActiveBrainId() + dispatches ados:active-brain-changed, which the rest of the app (GeneratorShell, dashboard, last-generated pill) already listens to — so all open forms reset and re-fill from the new brain instantly. - Dropdown footer: "+ Add new client" → /brand/new and "Manage all clients →" → /brand for power-user shortcuts. - When no client is set: the same block becomes an outlined CTA card with a Plus icon → /brand/new. Makes the empty state unmissable. - A subtle caption under the block reads "every tool below uses this client's brand brain" so the relationship is explicit. Dashboard recent generations · scoped to active client - listAds() now receives { brand_id: getActiveBrainId() } when a client is set. Section heading gets a "· scoped to active client" micro-label so the scoping is visible. - Live-updates on ados:active-brain-changed and ados:brains-changed so switching the active client immediately refreshes the recents. Last-generated pill · respects active client - The "● last: Campaign Kit" pill in PageHeader was showing the most-recent asset across ALL clients. Now hides itself when the pinned asset's brand_id doesn't match the current active brain — it stays accurate to the client the user is actually working on. - Live-updates on ados:active-brain-changed and ados:brains-changed. Brand-completeness indicator on each /brand client card - New "brain N/12" pill in each card's metadata strip: · green when ≥9 fields filled · live-orange when 5-8 (okay) · neg-red when <5 (thin — most generators will fall back to generic copy for some surfaces) - Tooltip explains why: "Generators read every field — thin brains produce more generic copy." - Counts the 12 brand-defining fields: business_name, industry, niche, USP, tone, audience_who, pain_points, desires, key_benefits, products, platforms, content_pillars. Verified: typecheck + 43 tests + build all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d062111 commit ef4ebe4

4 files changed

Lines changed: 218 additions & 9 deletions

File tree

app/brand/page.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ function BrandInner() {
149149
<div className="space-y-2 stagger">
150150
{brains.map((b) => {
151151
const isActive = b.id === activeBrandId;
152+
// Brain-completeness signal: how many of the 12 brand-defining fields
153+
// are filled? Below 50% means most generators will fall back to
154+
// generic copy for at least some surfaces.
155+
const completeness = brainCompleteness(b);
152156
return (
153157
<div
154158
key={b.id}
@@ -182,6 +186,15 @@ function BrandInner() {
182186
<span className="text-[10px] font-mono uppercase tracking-ui-mega text-ink-subtle">
183187
updated {new Date(b.updated_at).toLocaleDateString()}
184188
</span>
189+
<span className="text-[10px] text-ink-faint">·</span>
190+
<span
191+
className={`text-[10px] font-mono uppercase tracking-ui-mega ${
192+
completeness.tone === "good" ? "text-pos" : completeness.tone === "okay" ? "text-live" : "text-neg"
193+
}`}
194+
title={`${completeness.filled} of ${completeness.total} brand fields filled. Generators read every field — thin brains produce more generic copy.`}
195+
>
196+
brain {completeness.filled}/{completeness.total}
197+
</span>
185198
</div>
186199
<div className={`font-display italic text-xl ${isActive ? "text-live" : "text-ink"}`}>
187200
{b.name || b.business_name}
@@ -254,3 +267,27 @@ function BrandInner() {
254267
</div>
255268
);
256269
}
270+
271+
/**
272+
* Rough brain-completeness rating. Counts how many of the 12 generator-defining
273+
* fields are populated and buckets into good (≥9) / okay (5-8) / thin (<5).
274+
* Used by the client cards on /brand so users can see at-a-glance which
275+
* clients have skinny brains.
276+
*/
277+
function brainCompleteness(b: BrandBrain): { filled: number; total: number; tone: "good" | "okay" | "thin" } {
278+
const filled =
279+
(b.business_name ? 1 : 0) +
280+
(b.industry ? 1 : 0) +
281+
(b.niche ? 1 : 0) +
282+
(b.usp ? 1 : 0) +
283+
(b.tone ? 1 : 0) +
284+
(b.audience_who ? 1 : 0) +
285+
(b.audience_pain_points?.length ? 1 : 0) +
286+
(b.audience_desires?.length ? 1 : 0) +
287+
(b.key_benefits?.length ? 1 : 0) +
288+
(b.products?.length ? 1 : 0) +
289+
(b.platforms?.length ? 1 : 0) +
290+
(b.content_pillars?.length ? 1 : 0);
291+
const total = 12;
292+
return { filled, total, tone: filled >= 9 ? "good" : filled >= 5 ? "okay" : "thin" };
293+
}

app/page.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
66
import { Sparkles, Brain, History, Settings, Rocket, Target, FileBarChart, Activity, ClipboardList, BookOpen, Hash, ImageIcon, Search, GitBranch, Calendar } from "lucide-react";
77
import { PageHeader } from "@/components/PageHeader";
88
import { FeatureTour } from "@/components/FeatureTour";
9-
import { hasAnyKeyConfigured, isOnboarded } from "@/lib/settings";
9+
import { hasAnyKeyConfigured, isOnboarded, getActiveBrainId } from "@/lib/settings";
1010
import { listBrains, listAds, type GeneratedAd } from "@/lib/storage";
1111
import type { BrandBrain } from "@/lib/brand-brain";
1212

@@ -39,18 +39,35 @@ export default function Dashboard() {
3939
const router = useRouter();
4040
const [brains, setBrains] = useState<BrandBrain[]>([]);
4141
const [recent, setRecent] = useState<GeneratedAd[]>([]);
42+
const [activeBrainId, setActiveBrainIdState] = useState<string | null>(null);
4243
const [loading, setLoading] = useState(true);
4344

4445
useEffect(() => {
4546
if (!hasAnyKeyConfigured() || !isOnboarded()) {
4647
router.replace("/setup");
4748
return;
4849
}
49-
Promise.all([listBrains(), listAds()]).then(([b, a]) => {
50-
setBrains(b);
51-
setRecent(a.slice(0, 6));
52-
setLoading(false);
53-
});
50+
const load = () => {
51+
const active = getActiveBrainId();
52+
setActiveBrainIdState(active);
53+
// Scope recent generations to the active client when one is selected —
54+
// every other "active client" surface in the app does the same, so the
55+
// dashboard's recent list shouldn't be the one place showing other
56+
// clients' work.
57+
Promise.all([listBrains(), listAds(active ? { brand_id: active } : undefined)]).then(([b, a]) => {
58+
setBrains(b);
59+
setRecent(a.slice(0, 6));
60+
setLoading(false);
61+
});
62+
};
63+
load();
64+
const onChange = () => load();
65+
window.addEventListener("ados:brains-changed", onChange);
66+
window.addEventListener("ados:active-brain-changed", onChange);
67+
return () => {
68+
window.removeEventListener("ados:brains-changed", onChange);
69+
window.removeEventListener("ados:active-brain-changed", onChange);
70+
};
5471
}, [router]);
5572

5673
if (loading) return null;
@@ -117,7 +134,14 @@ export default function Dashboard() {
117134

118135
<section className="mt-10 stagger">
119136
<div className="flex items-center justify-between mb-3 hairline pb-2">
120-
<h2 className="text-[13px] font-semibold uppercase tracking-wider text-ink">Recent generations</h2>
137+
<h2 className="text-[13px] font-semibold uppercase tracking-wider text-ink">
138+
Recent generations
139+
{activeBrainId ? (
140+
<span className="ml-2 text-[10px] font-mono uppercase tracking-ui-wide text-live normal-case">
141+
· scoped to active client
142+
</span>
143+
) : null}
144+
</h2>
121145
<Link href="/history" className="text-[12px] font-medium uppercase tracking-wide text-live hover:underline">
122146
view all →
123147
</Link>

components/PageHeader.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from "next/link";
55
import { BrandSwitcher } from "./BrandSwitcher";
66
import { LiveDot } from "./LiveDot";
77
import { getLastGenerated, type LastGenerated } from "@/lib/next-steps";
8+
import { getActiveBrainId } from "@/lib/settings";
89

910
interface Props {
1011
scope: string; // e.g. "google/rsa"
@@ -43,17 +44,30 @@ export function PageHeader({ scope, title, subtitle, showLive, actions }: Props)
4344
*/
4445
function LastGeneratedPill() {
4546
const [last, setLast] = useState<LastGenerated | null>(null);
47+
const [activeBrandId, setActiveBrandId] = useState<string | null>(null);
4648
useEffect(() => {
47-
setLast(getLastGenerated());
48-
const refresh = () => setLast(getLastGenerated());
49+
const refresh = () => {
50+
setLast(getLastGenerated());
51+
setActiveBrandId(getActiveBrainId());
52+
};
53+
refresh();
4954
window.addEventListener("ados:last-generated-changed", refresh);
55+
window.addEventListener("ados:active-brain-changed", refresh);
56+
window.addEventListener("ados:brains-changed", refresh);
5057
window.addEventListener("storage", refresh);
5158
return () => {
5259
window.removeEventListener("ados:last-generated-changed", refresh);
60+
window.removeEventListener("ados:active-brain-changed", refresh);
61+
window.removeEventListener("ados:brains-changed", refresh);
5362
window.removeEventListener("storage", refresh);
5463
};
5564
}, []);
5665
if (!last) return null;
66+
// Hide the pill when the most recent asset belongs to a different client than
67+
// the one currently active — otherwise the header advertises a client the
68+
// user just switched away from. The asset is still in History, just not
69+
// pinned here for the wrong context.
70+
if (activeBrandId && last.brand_id && last.brand_id !== activeBrandId) return null;
5771
return (
5872
<Link
5973
href={`/history?focus=${encodeURIComponent(last.id)}`}

components/Sidebar.tsx

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
55
import { useEffect, useState } from "react";
66
import {
77
ChevronRight,
8+
ChevronDown,
89
Sparkles,
910
Facebook,
1011
Search as SearchIcon,
@@ -18,10 +19,16 @@ import {
1819
GraduationCap,
1920
Database,
2021
Lock,
22+
Brain,
23+
Plus,
24+
Check,
2125
} from "lucide-react";
2226
import type { LucideIcon } from "lucide-react";
2327
import { cn } from "@/lib/utils";
2428
import { NAV_GROUPS } from "./nav-config";
29+
import { listBrains } from "@/lib/storage";
30+
import { getActiveBrainId, setActiveBrainId } from "@/lib/settings";
31+
import type { BrandBrain } from "@/lib/brand-brain";
2532

2633
// Lucide doesn't ship a Google-G icon by default; use SearchIcon as the closest
2734
// semantic fit (Google = search ads in this app). Same for TikTok → Music2.
@@ -48,6 +55,37 @@ export function Sidebar() {
4855
return init;
4956
});
5057

58+
// Active-client tracking: every tool in the sidebar reads the active brain,
59+
// so the sidebar shows which brain that is + lets the user quick-switch.
60+
const [brains, setBrains] = useState<BrandBrain[]>([]);
61+
const [activeBrandId, setActiveBrandIdState] = useState<string | null>(null);
62+
const [switcherOpen, setSwitcherOpen] = useState(false);
63+
64+
useEffect(() => {
65+
let mounted = true;
66+
const load = async () => {
67+
const list = await listBrains();
68+
if (!mounted) return;
69+
setBrains(list);
70+
setActiveBrandIdState(getActiveBrainId());
71+
};
72+
load();
73+
const onChange = () => load();
74+
window.addEventListener("ados:brains-changed", onChange);
75+
window.addEventListener("ados:active-brain-changed", onChange);
76+
return () => {
77+
mounted = false;
78+
window.removeEventListener("ados:brains-changed", onChange);
79+
window.removeEventListener("ados:active-brain-changed", onChange);
80+
};
81+
}, []);
82+
83+
function pickBrand(id: string) {
84+
setActiveBrainId(id);
85+
setActiveBrandIdState(id);
86+
setSwitcherOpen(false);
87+
}
88+
5189
// Auto-expand the group containing the current path so users never have a
5290
// hidden active item.
5391
useEffect(() => {
@@ -62,6 +100,8 @@ export function Sidebar() {
62100
});
63101
}, [path]);
64102

103+
const activeBrand = brains.find((b) => b.id === activeBrandId);
104+
65105
return (
66106
<aside
67107
className={cn(
@@ -94,6 +134,100 @@ export function Sidebar() {
94134
</Link>
95135
</div>
96136

137+
{/* Active-client block — persistent reminder of which brain every tool
138+
below this point will use. Click to quick-switch between saved clients;
139+
no client yet → CTA into the onboarding flow. */}
140+
<div className="border-b border-base-700/60 px-3 py-3 relative">
141+
{activeBrand ? (
142+
<>
143+
<button
144+
onClick={() => setSwitcherOpen((o) => !o)}
145+
className="w-full flex items-center gap-2.5 group text-left"
146+
aria-expanded={switcherOpen}
147+
aria-haspopup="listbox"
148+
>
149+
<div className="shrink-0 h-7 w-7 grid place-items-center bg-live/20 border border-live/40 rounded-sm">
150+
<Brain size={12} className="text-live" />
151+
</div>
152+
<div className="flex-1 min-w-0">
153+
<div className="text-[9px] font-mono uppercase tracking-ui-mega text-ink-faint leading-none">active client</div>
154+
<div className="text-[13px] text-ink font-medium truncate leading-tight mt-0.5">
155+
{activeBrand.name || activeBrand.business_name}
156+
</div>
157+
</div>
158+
<ChevronDown size={12} className={cn("text-ink-faint shrink-0 transition-transform", switcherOpen && "rotate-180")} />
159+
</button>
160+
161+
{switcherOpen ? (
162+
<div className="absolute left-2 right-2 top-full mt-1 z-40 bg-base-900 border border-base-600 shadow-2xl animate-fade-up max-h-[60vh] overflow-y-auto">
163+
<div className="px-3 py-2 border-b border-base-700/60 text-[9px] font-mono uppercase tracking-ui-mega text-ink-faint">
164+
switch active client
165+
</div>
166+
<ul role="listbox" className="py-1">
167+
{brains.map((b) => {
168+
const isActive = b.id === activeBrandId;
169+
return (
170+
<li key={b.id}>
171+
<button
172+
onClick={() => pickBrand(b.id)}
173+
role="option"
174+
aria-selected={isActive}
175+
className={cn(
176+
"w-full text-left px-3 py-2 text-[13px] flex items-center gap-2 transition-colors",
177+
isActive ? "bg-live/10 text-live" : "text-ink-muted hover:bg-base-800 hover:text-ink"
178+
)}
179+
>
180+
{isActive ? <Check size={11} className="shrink-0" /> : <span className="w-[11px] shrink-0" />}
181+
<div className="flex-1 min-w-0">
182+
<div className="truncate">{b.name || b.business_name}</div>
183+
{b.industry ? (
184+
<div className="text-[10px] font-mono uppercase tracking-ui-wide text-ink-faint truncate">{b.industry}</div>
185+
) : null}
186+
</div>
187+
</button>
188+
</li>
189+
);
190+
})}
191+
</ul>
192+
<div className="border-t border-base-700/60 p-1">
193+
<Link
194+
href="/brand/new"
195+
onClick={() => setSwitcherOpen(false)}
196+
className="flex items-center gap-2 px-3 py-2 text-[12px] text-live hover:bg-live/10 transition-colors"
197+
>
198+
<Plus size={11} /> Add new client
199+
</Link>
200+
<Link
201+
href="/brand"
202+
onClick={() => setSwitcherOpen(false)}
203+
className="flex items-center gap-2 px-3 py-2 text-[12px] text-ink-muted hover:text-ink hover:bg-base-800 transition-colors"
204+
>
205+
Manage all clients →
206+
</Link>
207+
</div>
208+
</div>
209+
) : null}
210+
211+
<p className="text-[9px] font-mono uppercase tracking-ui-wide text-ink-subtle mt-2 leading-relaxed">
212+
every tool below uses this client's brand brain
213+
</p>
214+
</>
215+
) : (
216+
<Link
217+
href="/brand/new"
218+
className="flex items-center gap-2.5 group p-1.5 rounded-sm border border-live/40 bg-live/[0.04] hover:bg-live/10 transition-colors"
219+
>
220+
<div className="shrink-0 h-7 w-7 grid place-items-center bg-base-900 border border-live/40 rounded-sm">
221+
<Plus size={12} className="text-live" />
222+
</div>
223+
<div className="flex-1 min-w-0">
224+
<div className="text-[9px] font-mono uppercase tracking-ui-mega text-live leading-none">no client yet</div>
225+
<div className="text-[12px] text-ink leading-tight mt-0.5">Add your first client →</div>
226+
</div>
227+
</Link>
228+
)}
229+
</div>
230+
97231
{/* Nav */}
98232
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1 sidebar-nav">
99233
{NAV_GROUPS.map((g) => {

0 commit comments

Comments
 (0)