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
|
## Hierarchy
|
||||||
|
|
||||||
```
|
```
|
||||||
CEO (Renzo - Human)
|
CEO (Human)
|
||||||
|
|
|
|
||||||
+-- Board (4 agents)
|
+-- Board (4 agents)
|
||||||
| +-- Product Owner
|
| +-- Product Owner
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
- **Agent**: ceo (Renzo - Human)
|
- **Agent**: ceo (Human)
|
||||||
- **Role**: `ceo`
|
- **Role**: `ceo`
|
||||||
- **Team**: board
|
- **Team**: board
|
||||||
- **Reports to**: N/A (top of hierarchy)
|
- **Reports to**: N/A (top of hierarchy)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ vi.mock("next-themes", () => ({
|
|||||||
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
|
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/settings/user-info-card", () => ({
|
||||||
|
UserInfoCard: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/settings/transcript-retention-card", () => ({
|
vi.mock("@/components/settings/transcript-retention-card", () => ({
|
||||||
TranscriptRetentionCard: () => null,
|
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("Enable Notifications", "switch")).not.toBeChecked();
|
||||||
expect(controlFor("Sound Alerts", "switch")).not.toBeChecked();
|
expect(controlFor("Sound Alerts", "switch")).not.toBeChecked();
|
||||||
expect(controlFor("Auto Refresh", "switch")).toBeChecked();
|
expect(controlFor("Auto Refresh", "switch")).toBeChecked();
|
||||||
expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent(
|
expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent("1m");
|
||||||
"1m",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggling Auto Refresh calls setAutoRefresh directly — no edits/save step", () => {
|
it("toggling Auto Refresh calls setAutoRefresh directly — no edits/save step", () => {
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { HelpTip } from "@/components/ui/help-tip";
|
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 { API_URL, WS_URL } from "@/lib/constants";
|
||||||
|
import { UserInfoCard } from "@/components/settings/user-info-card";
|
||||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||||
import { FeatureFlagsCard } from "@/components/settings/feature-flags-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). */}
|
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2). */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<Card>
|
<UserInfoCard />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { Header } from "../header";
|
import { Header } from "../header";
|
||||||
import { PageRefreshProvider } from "@/components/providers";
|
import { PageRefreshProvider } from "@/components/providers";
|
||||||
@@ -9,6 +10,14 @@ vi.mock("next-themes", () => ({
|
|||||||
useTheme: () => ({ theme: "system", setTheme: vi.fn() }),
|
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", () => ({
|
vi.mock("@/hooks/use-websocket", () => ({
|
||||||
useNotificationStream: () => ({
|
useNotificationStream: () => ({
|
||||||
notifications: [],
|
notifications: [],
|
||||||
@@ -30,7 +39,14 @@ vi.mock("@/components/notifications/notification-bell", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function withPageRefresh(ui: ReactNode) {
|
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({
|
function RefreshRegistrator({
|
||||||
@@ -163,3 +179,22 @@ describe("Header — navbar refresh button", () => {
|
|||||||
await waitFor(() => expect(refreshButton).not.toBeDisabled());
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Search, Sun, Moon, Monitor, RefreshCw } from "lucide-react";
|
import { Search, Sun, Moon, Monitor, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -22,12 +23,23 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePageRefresh } from "@/hooks";
|
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";
|
const REFRESH_LABEL = "Refresh only the current page";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const { refresh, loading, disabled } = usePageRefresh();
|
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 (
|
return (
|
||||||
<header className="flex h-16 items-center justify-between border-b bg-background px-6">
|
<header className="flex h-16 items-center justify-between border-b bg-background px-6">
|
||||||
@@ -120,7 +132,7 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium hidden sm:inline">
|
<span className="text-sm font-medium hidden sm:inline">
|
||||||
Renzo
|
{ceoName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</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";
|
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 {
|
export interface SettingsResponse {
|
||||||
settings: Record<string, string>;
|
settings: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ def _validate_bool(value: str) -> None:
|
|||||||
raise SettingValidationError("value must be 'true' or 'false'")
|
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
|
# Panel-tunable feature flags (master switches). The stored value overrides the
|
||||||
# config/env default at startup via ``apply_persisted_feature_flags`` — i.e. a
|
# 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
|
# 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.
|
# the panel can only persist values the backend understands.
|
||||||
_VALIDATORS = {
|
_VALIDATORS = {
|
||||||
"transcript_retention_days": _validate_retention_days,
|
"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
|
# Telegram inbound's getUpdates offset cursor. Not a feature flag (absent
|
||||||
# from FEATURE_FLAGS/the panel card) but reuses this same validated KV
|
# 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.
|
# 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")
|
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
|
_DEFAULT_RETENTION = 14
|
||||||
_NEW_RETENTION = 30
|
_NEW_RETENTION = 30
|
||||||
|
|
||||||
@@ -54,3 +62,18 @@ async def test_set_rejects_invalid_value(db_session: Any) -> None:
|
|||||||
svc = get_settings_service(db_session)
|
svc = get_settings_service(db_session)
|
||||||
with pytest.raises(SettingValidationError):
|
with pytest.raises(SettingValidationError):
|
||||||
await svc.set("transcript_retention_days", "-5")
|
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