The profile had its own settings tab although it is a few rows of personal details. It now sits as one card under Account in General, hides itself while empty, and the separate tab is gone. Claude-Session: https://claude.ai/code/session_017gHYZ4c9Twex73v4ZasmMJ Co-authored-by: Claude <noreply@anthropic.com>
303 lines
9.4 KiB
TypeScript
303 lines
9.4 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import type { UserFactCategory, UserFactSource, UserProfileState } from "@rakazo/contracts";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
Button,
|
|
} from "@rakazo/ui-web";
|
|
import { PencilIcon, XIcon } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { useEffect, useId, useState } from "react";
|
|
import { getActiveUiLocale } from "../lib/i18n";
|
|
import { rpc } from "../lib/rpc";
|
|
import type { ProfileField } from "../lib/user-profile-view";
|
|
import {
|
|
CLEARED_PROFILE_FIELD,
|
|
displayLanguageTag,
|
|
displayRegionTag,
|
|
factSpaceNames,
|
|
filledProfileFields,
|
|
groupUserFacts,
|
|
userFactEdit,
|
|
} from "../lib/user-profile-view";
|
|
import { UserFactEditor } from "./UserFactEditor";
|
|
|
|
function Row({
|
|
value,
|
|
detail,
|
|
actions,
|
|
testId,
|
|
status,
|
|
}: {
|
|
value: string;
|
|
detail: string;
|
|
actions: (describedBy: string) => ReactNode;
|
|
testId: string;
|
|
status?: string;
|
|
}) {
|
|
const valueId = useId();
|
|
return (
|
|
<li data-testid={testId} data-status={status} className="flex items-center gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p id={valueId} className="text-ui-body break-words text-foreground/75">
|
|
{value}
|
|
</p>
|
|
<p className="text-ui-body-sm text-muted-foreground/70">{detail}</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">{actions(valueId)}</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** Everything the user's bots know about the user, with edit, consent and forget controls. */
|
|
export function UserProfileSettings() {
|
|
const { t } = useLingui();
|
|
const [state, setState] = useState<UserProfileState | null>(null);
|
|
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [forgetOpen, setForgetOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
// Only names a fact's spaces; without them the profile still works.
|
|
rpc.spaces
|
|
.list()
|
|
.then((navigation) => {
|
|
if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name })));
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
rpc.profile
|
|
.get()
|
|
.then((next) => {
|
|
if (!cancelled) setState(next);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [t]);
|
|
|
|
async function apply(change: () => Promise<UserProfileState>): Promise<boolean> {
|
|
if (busy) return false;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
setState(await change());
|
|
return true;
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t`Couldn't save`);
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const categoryLabels: Record<UserFactCategory, string> = {
|
|
identity: t`Identity`,
|
|
work: t`Work`,
|
|
goals: t`Goals`,
|
|
people: t`People`,
|
|
health: t`Health`,
|
|
values: t`Values`,
|
|
habits: t`Habits`,
|
|
preferences: t`Preferences`,
|
|
tools: t`Tools`,
|
|
};
|
|
const sourceLabels: Record<UserFactSource, string> = {
|
|
onboarding: t`Onboarding`,
|
|
user: t`You`,
|
|
conversation: t`Chat`,
|
|
consolidation: t`Chat history`,
|
|
connector: t`Connected app`,
|
|
};
|
|
const fieldLabels: Record<ProfileField, string> = {
|
|
timeZone: t`Time zone`,
|
|
language: t`Language`,
|
|
locale: t`Region`,
|
|
communicationStyle: t`Communication style`,
|
|
summary: t`Summary`,
|
|
};
|
|
|
|
const fields = state ? filledProfileFields(state.profile) : [];
|
|
const groups = state ? groupUserFacts(state.facts) : [];
|
|
const uiLocale = getActiveUiLocale();
|
|
|
|
const deleteButton = (describedBy: string, onClick: () => void) => (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={t`Delete`}
|
|
aria-describedby={describedBy}
|
|
disabled={busy}
|
|
onClick={onClick}
|
|
>
|
|
<XIcon />
|
|
</Button>
|
|
);
|
|
|
|
if (!error && (!state || (fields.length === 0 && groups.length === 0))) return null;
|
|
|
|
return (
|
|
<section
|
|
data-testid="user-profile-settings"
|
|
className="space-y-4 rounded-xl border border-border px-4 py-4"
|
|
>
|
|
<h3 className="text-ui-body font-medium text-foreground">
|
|
<Trans>About you</Trans>
|
|
</h3>
|
|
{error ? (
|
|
<p role="alert" className="text-ui-body-sm text-destructive">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
{fields.length > 0 ? (
|
|
<ul className="space-y-3">
|
|
{fields.map(({ field, value }) => (
|
|
<Row
|
|
key={field}
|
|
testId={`profile-field-${field}`}
|
|
value={
|
|
field === "language"
|
|
? displayLanguageTag(value, uiLocale)
|
|
: field === "locale"
|
|
? displayRegionTag(value, uiLocale)
|
|
: value
|
|
}
|
|
detail={fieldLabels[field]}
|
|
actions={(describedBy) =>
|
|
deleteButton(
|
|
describedBy,
|
|
() => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])),
|
|
)
|
|
}
|
|
/>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{groups.map((group) => (
|
|
<div key={group.category} data-testid={`user-facts-${group.category}`}>
|
|
<h4 className="text-ui-body-sm text-muted-foreground">
|
|
{categoryLabels[group.category]}
|
|
</h4>
|
|
<ul className="mt-2 space-y-3">
|
|
{group.facts.map((fact) =>
|
|
fact.id === editingId ? (
|
|
<UserFactEditor
|
|
key={fact.id}
|
|
fact={fact}
|
|
spaces={spaces}
|
|
categoryLabels={categoryLabels}
|
|
busy={busy}
|
|
onCancel={() => setEditingId(null)}
|
|
onSave={(draft) => {
|
|
const edit = userFactEdit(fact, draft);
|
|
if (!edit) {
|
|
setEditingId(null);
|
|
return;
|
|
}
|
|
void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => {
|
|
if (saved) setEditingId(null);
|
|
});
|
|
}}
|
|
/>
|
|
) : (
|
|
<Row
|
|
key={fact.id}
|
|
testId="user-fact"
|
|
status={fact.status}
|
|
value={fact.value}
|
|
detail={[
|
|
sourceLabels[fact.source],
|
|
factSpaceNames(fact.allSpaces ? null : fact.spaceIds, spaces).join(", "),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
actions={(describedBy) => (
|
|
<>
|
|
{fact.status === "pending" ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
aria-describedby={describedBy}
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
|
|
}
|
|
>
|
|
<Trans>Confirm</Trans>
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={t`Edit`}
|
|
aria-describedby={describedBy}
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setError(null);
|
|
setEditingId(fact.id);
|
|
}}
|
|
>
|
|
<PencilIcon />
|
|
</Button>
|
|
{deleteButton(
|
|
describedBy,
|
|
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
|
|
)}
|
|
</>
|
|
)}
|
|
/>
|
|
),
|
|
)}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
{fields.length > 0 || groups.length > 0 ? (
|
|
<Button variant="destructive" disabled={busy} onClick={() => setForgetOpen(true)}>
|
|
<Trans>Forget everything</Trans>
|
|
</Button>
|
|
) : null}
|
|
<AlertDialog open={forgetOpen} onOpenChange={setForgetOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
<Trans>Forget everything about you?</Trans>
|
|
</AlertDialogTitle>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
<Trans>Cancel</Trans>
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setForgetOpen(false);
|
|
void apply(() => rpc.profile.forget({ all: true }));
|
|
}}
|
|
>
|
|
<Trans>Forget</Trans>
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</section>
|
|
);
|
|
}
|