mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -3,7 +3,7 @@
|
||||
## Hierarchy
|
||||
|
||||
```
|
||||
CEO (Renzo - Human)
|
||||
CEO (Human)
|
||||
|
|
||||
+-- Board (4 agents)
|
||||
| +-- Product Owner
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Identity
|
||||
|
||||
- **Agent**: ceo (Renzo - Human)
|
||||
- **Agent**: ceo (Human)
|
||||
- **Role**: `ceo`
|
||||
- **Team**: board
|
||||
- **Reports to**: N/A (top of hierarchy)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ def _validate_bool(value: str) -> None:
|
||||
raise SettingValidationError("value must be 'true' or 'false'")
|
||||
|
||||
|
||||
_CEO_NAME_MAX_LEN = 60
|
||||
|
||||
|
||||
def _validate_ceo_name(value: str) -> None:
|
||||
if not value.strip():
|
||||
raise SettingValidationError("ceo_name must not be empty")
|
||||
if len(value.strip()) > _CEO_NAME_MAX_LEN:
|
||||
raise SettingValidationError(
|
||||
f"ceo_name must be at most {_CEO_NAME_MAX_LEN} characters"
|
||||
)
|
||||
|
||||
|
||||
# Panel-tunable feature flags (master switches). The stored value overrides the
|
||||
# config/env default at startup via ``apply_persisted_feature_flags`` — i.e. a
|
||||
# toggle takes effect on the next restart, replacing hand-editing env. Each maps
|
||||
@@ -101,6 +113,11 @@ def _validate_update_id(value: str) -> None:
|
||||
# the panel can only persist values the backend understands.
|
||||
_VALIDATORS = {
|
||||
"transcript_retention_days": _validate_retention_days,
|
||||
# The CEO's panel display name (header chip + Settings User Info card).
|
||||
# No config/migration involved — an unset key just means the panel's
|
||||
# own hardcoded "Renzo" default renders, same as transcript retention's
|
||||
# client-side DEFAULT_RETENTION fallback.
|
||||
"ceo_name": _validate_ceo_name,
|
||||
# Telegram inbound's getUpdates offset cursor. Not a feature flag (absent
|
||||
# from FEATURE_FLAGS/the panel card) but reuses this same validated KV
|
||||
# store instead of a dedicated table, so a restart doesn't replay updates.
|
||||
|
||||
@@ -25,6 +25,14 @@ def test_validate_retention_requires_positive_int() -> None:
|
||||
validate_setting("transcript_retention_days", "abc")
|
||||
|
||||
|
||||
def test_validate_ceo_name_requires_nonempty_bounded_string() -> None:
|
||||
validate_setting("ceo_name", "Alice") # ok, no raise
|
||||
with pytest.raises(SettingValidationError):
|
||||
validate_setting("ceo_name", " ")
|
||||
with pytest.raises(SettingValidationError):
|
||||
validate_setting("ceo_name", "x" * 61)
|
||||
|
||||
|
||||
_DEFAULT_RETENTION = 14
|
||||
_NEW_RETENTION = 30
|
||||
|
||||
@@ -54,3 +62,18 @@ async def test_set_rejects_invalid_value(db_session: Any) -> None:
|
||||
svc = get_settings_service(db_session)
|
||||
with pytest.raises(SettingValidationError):
|
||||
await svc.set("transcript_retention_days", "-5")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_name_set_then_get_roundtrips(db_session: Any) -> None:
|
||||
svc = get_settings_service(db_session)
|
||||
assert await svc.get("ceo_name") is None # unset, panel supplies fallback
|
||||
await svc.set("ceo_name", "Alice")
|
||||
assert await svc.get("ceo_name") == "Alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_name_set_rejects_blank_value(db_session: Any) -> None:
|
||||
svc = get_settings_service(db_session)
|
||||
with pytest.raises(SettingValidationError):
|
||||
await svc.set("ceo_name", " ")
|
||||
|
||||
Reference in New Issue
Block a user