feat(settings): CEO display name is configurable, Renzo hardcode removed (#612)

The header chip and the Settings User Info card rendered a literal 'Renzo'.
The name now lives in the system_settings store under ceo_name (validated:
trimmed, non-empty, max 60 chars) with the same client-served default the
transcript-retention card uses, editable inline from the User Info card.
Agent prompts already refer to 'the CEO' generically, so no prompt rewiring;
the two agent-facing RAG docs drop the name too. License/CLA copyright is
untouched.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-20 20:38:44 +02:00
committed by GitHub
co-authored by Renn F
parent 8cb233c1e8
commit e125ef08aa
10 changed files with 217 additions and 37 deletions
@@ -25,6 +25,10 @@ vi.mock("next-themes", () => ({
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
}));
vi.mock("@/components/settings/user-info-card", () => ({
UserInfoCard: () => null,
}));
vi.mock("@/components/settings/transcript-retention-card", () => ({
TranscriptRetentionCard: () => null,
}));
@@ -85,9 +89,7 @@ describe("SettingsPage — client-only prefs (store-driven, no server round trip
expect(controlFor("Enable Notifications", "switch")).not.toBeChecked();
expect(controlFor("Sound Alerts", "switch")).not.toBeChecked();
expect(controlFor("Auto Refresh", "switch")).toBeChecked();
expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent(
"1m",
);
expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent("1m");
});
it("toggling Auto Refresh calls setAutoRefresh directly — no edits/save step", () => {
+3 -30
View File
@@ -21,8 +21,9 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { HelpTip } from "@/components/ui/help-tip";
import { Settings, Palette, Bell, Server, User } from "lucide-react";
import { Settings, Palette, Bell, Server } from "lucide-react";
import { API_URL, WS_URL } from "@/lib/constants";
import { UserInfoCard } from "@/components/settings/user-info-card";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
@@ -56,35 +57,7 @@ export default function SettingsPage() {
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2). */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
User Info
</CardTitle>
<CardDescription>Your account information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl">
CEO
</span>
</div>
<div>
<p className="font-semibold text-lg">Renzo</p>
<p className="text-sm text-muted-foreground">
Chief Executive Officer
</p>
<HelpTip label="The CEO's fixed agent id — used to attribute your notifications, notes, and approvals across the API.">
<p className="text-xs text-muted-foreground mt-1 w-fit">
Agent ID: 00000000-0000-0000-0000-000000000001
</p>
</HelpTip>
</div>
</div>
</CardContent>
</Card>
<UserInfoCard />
{/* Appearance */}
<Card>
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { Header } from "../header";
import { PageRefreshProvider } from "@/components/providers";
@@ -9,6 +10,14 @@ vi.mock("next-themes", () => ({
useTheme: () => ({ theme: "system", setTheme: vi.fn() }),
}));
// Header now reads the shared ["settings"] query for ceo_name; stub it so
// tests don't hit the network. Individual tests can override the resolved
// value via mockResolvedValueOnce.
const { getAll } = vi.hoisted(() => ({
getAll: vi.fn(async () => ({}) as Record<string, string>),
}));
vi.mock("@/lib/api", () => ({ settingsApi: { getAll } }));
vi.mock("@/hooks/use-websocket", () => ({
useNotificationStream: () => ({
notifications: [],
@@ -30,7 +39,14 @@ vi.mock("@/components/notifications/notification-bell", () => ({
}));
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={client}>
<PageRefreshProvider>{ui}</PageRefreshProvider>
</QueryClientProvider>
);
}
function RefreshRegistrator({
@@ -163,3 +179,22 @@ describe("Header — navbar refresh button", () => {
await waitFor(() => expect(refreshButton).not.toBeDisabled());
});
});
describe("Header — CEO name chip (ceo_name setting)", () => {
beforeEach(() => {
getAll.mockClear();
});
it("falls back to the default name while the settings query is unset", async () => {
getAll.mockResolvedValueOnce({});
render(withPageRefresh(<Header />));
expect(await screen.findByText("Renzo")).toBeInTheDocument();
});
it("renders the persisted ceo_name once the settings query resolves", async () => {
getAll.mockResolvedValueOnce({ ceo_name: "Alice" });
render(withPageRefresh(<Header />));
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.queryByText("Renzo")).not.toBeInTheDocument();
});
});
+13 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import { useQuery } from "@tanstack/react-query";
import { Search, Sun, Moon, Monitor, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,12 +23,23 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { usePageRefresh } from "@/hooks";
import { settingsApi } from "@/lib/api";
import { CEO_NAME_KEY, DEFAULT_CEO_NAME } from "@/lib/api/settings";
const REFRESH_LABEL = "Refresh only the current page";
export function Header() {
const { setTheme } = useTheme();
const { refresh, loading, disabled } = usePageRefresh();
// Same ["settings"] query key as the Settings page's User Info card — the
// app-wide react-query cache means whichever loads first primes the other.
// Falls back to the config default while loading/unset, so there's no
// flash of a wrong name.
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: settingsApi.getAll,
});
const ceoName = settings?.[CEO_NAME_KEY] ?? DEFAULT_CEO_NAME;
return (
<header className="flex h-16 items-center justify-between border-b bg-background px-6">
@@ -120,7 +132,7 @@ export function Header() {
</span>
</div>
<span className="text-sm font-medium hidden sm:inline">
Renzo
{ceoName}
</span>
</div>
</TooltipTrigger>
@@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { settingsApi } from "@/lib/api";
import { CEO_NAME_KEY, DEFAULT_CEO_NAME } from "@/lib/api/settings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { User, Save } from "lucide-react";
import { toast } from "sonner";
const MAX_NAME_LENGTH = 60;
export function UserInfoCard() {
const queryClient = useQueryClient();
// `edited` holds the in-progress input; null means "show the server value"
// (same pattern as TranscriptRetentionCard — avoids syncing query state
// into local state with an effect).
const [edited, setEdited] = useState<string | null>(null);
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: settingsApi.getAll,
});
const serverValue = settings?.[CEO_NAME_KEY] ?? DEFAULT_CEO_NAME;
const name = edited ?? serverValue;
const saveMutation = useMutation({
mutationFn: (value: string) => settingsApi.update(CEO_NAME_KEY, value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setEdited(null);
toast.success("Name updated");
},
onError: (error) => {
toast.error(
`Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
});
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Name can't be empty");
return;
}
saveMutation.mutate(trimmed);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
User Info
</CardTitle>
<CardDescription>Your account information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 shrink-0 rounded-full bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl">
CEO
</span>
</div>
<div className="flex-1 space-y-2">
<HelpTip label="Shown in the header's user chip and this card. Doesn't change your git/legal identity — that stays with the repo's copyright owner.">
<Label htmlFor="ceo-name">Display name</Label>
</HelpTip>
<div className="flex items-center gap-2">
<Input
id="ceo-name"
value={name}
disabled={isLoading}
onChange={(e) => setEdited(e.target.value)}
maxLength={MAX_NAME_LENGTH}
className="max-w-[200px]"
/>
<Button
size="sm"
onClick={handleSave}
disabled={saveMutation.isPending || isLoading}
>
<Save className="h-4 w-4 mr-2" />
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Chief Executive Officer
</p>
<HelpTip label="The CEO's fixed agent id — used to attribute your notifications, notes, and approvals across the API.">
<p className="text-xs text-muted-foreground w-fit">
Agent ID: 00000000-0000-0000-0000-000000000001
</p>
</HelpTip>
</div>
</div>
</CardContent>
</Card>
);
}
+6
View File
@@ -1,5 +1,11 @@
import api from "./client";
// The CEO's panel display name — header user chip + Settings User Info card.
// Unset key (no row yet) falls back to this default; current behavior until
// the CEO edits it in Settings.
export const CEO_NAME_KEY = "ceo_name";
export const DEFAULT_CEO_NAME = "Renzo";
export interface SettingsResponse {
settings: Record<string, string>;
}