profile: wire bio/phone/location/timezone to arcadia

Adds a "Profile" card backed by /api/v1/profile (PATCH) for the four
public-profile fields arcadia already had columns for. Bio moved out of
local prefs (the server one supersedes); local prefs keeps only title,
signature, defaultAgentId, and the avatar URL mirror.

Save/revert mirror the existing Account card's pattern. The new fields
get arcadia validation + audit logging for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-05 09:49:34 +10:00
parent c2730e3c77
commit f6a92118da
2 changed files with 155 additions and 20 deletions

View File

@@ -1,12 +1,13 @@
// Local user preferences — title, bio, signature, avatar, default agent. // Local user preferences — title, signature, default agent, plus a cache
// Persisted in localStorage; reactive across tabs. Identity (name, email) // mirror of the resolved avatar URL. Persisted in localStorage; reactive
// is owned by the arcadia session, not this store — see ~/lib/session.ts. // across tabs. Identity (name, email) is owned by the arcadia session
// (~/lib/session.ts); the public profile (bio, phone, location, timezone)
// is server-backed via /api/v1/profile (~/lib/arcadia/profiles.ts).
import { useEffect, useSyncExternalStore } from "react" import { useEffect, useSyncExternalStore } from "react"
export type Profile = { export type Profile = {
title: string title: string
bio: string
signature: string signature: string
avatarUrl: string avatarUrl: string
defaultAgentId: string defaultAgentId: string
@@ -14,7 +15,6 @@ export type Profile = {
export const DEFAULT_PROFILE: Profile = { export const DEFAULT_PROFILE: Profile = {
title: "", title: "",
bio: "",
signature: "", signature: "",
avatarUrl: "", avatarUrl: "",
defaultAgentId: "", defaultAgentId: "",
@@ -32,7 +32,6 @@ function readFromStorage(): Profile {
return { return {
title: title:
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title, typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
bio: typeof parsed.bio === "string" ? parsed.bio : DEFAULT_PROFILE.bio,
signature: signature:
typeof parsed.signature === "string" typeof parsed.signature === "string"
? parsed.signature ? parsed.signature

View File

@@ -82,6 +82,24 @@ export default function ProfileRoute() {
const [avatarUploading, setAvatarUploading] = useState(false) const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarError, setAvatarError] = useState<string | null>(null) const [avatarError, setAvatarError] = useState<string | null>(null)
// Public-profile editable fields, server-backed via PATCH /api/v1/profile.
const [profileDraft, setProfileDraft] = useState<{
bio: string
phone: string
location: string
timezone: string
}>({ bio: "", phone: "", location: "", timezone: "" })
const [profileSaving, setProfileSaving] = useState(false)
const [profileSavedAt, setProfileSavedAt] = useState<number | null>(null)
const [profileError, setProfileError] = useState<string | null>(null)
const profileDirty =
!!arcadiaProfile &&
(profileDraft.bio !== (arcadiaProfile.bio ?? "") ||
profileDraft.phone !== (arcadiaProfile.phone ?? "") ||
profileDraft.location !== (arcadiaProfile.location ?? "") ||
profileDraft.timezone !== (arcadiaProfile.timezone ?? ""))
const loadAccount = useCallback(async () => { const loadAccount = useCallback(async () => {
if (!session) return if (!session) return
setAccountLoading(true) setAccountLoading(true)
@@ -99,6 +117,12 @@ export default function ProfileRoute() {
}) })
if (p) { if (p) {
setArcadiaProfile(p) setArcadiaProfile(p)
setProfileDraft({
bio: p.bio ?? "",
phone: p.phone ?? "",
location: p.location ?? "",
timezone: p.timezone ?? "",
})
const url = pickAvatarUrl(p) const url = pickAvatarUrl(p)
if (url) setPrefs((d) => ({ ...d, avatarUrl: url })) if (url) setPrefs((d) => ({ ...d, avatarUrl: url }))
} }
@@ -143,6 +167,37 @@ export default function ProfileRoute() {
} }
} }
const saveArcadiaProfile = async () => {
setProfileSaving(true)
setProfileError(null)
try {
const updated = await updateArcadiaProfile(arcadia, {
bio: profileDraft.bio || null,
phone: profileDraft.phone || null,
location: profileDraft.location || null,
timezone: profileDraft.timezone || null,
})
setArcadiaProfile(updated)
setProfileDraft({
bio: updated.bio ?? "",
phone: updated.phone ?? "",
location: updated.location ?? "",
timezone: updated.timezone ?? "",
})
setProfileSavedAt(Date.now())
} catch (err) {
setProfileError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
setProfileSaving(false)
}
}
// Local prefs handlers. // Local prefs handlers.
const initials = profileInitials( const initials = profileInitials(
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") || [accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
@@ -366,12 +421,106 @@ export default function ProfileRoute() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>
Public profile fields stored on arcadia. Visible to other members
of this tenant.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{profileError ? (
<AlertBanner variant="error">{profileError}</AlertBanner>
) : null}
<Field
label="Bio"
hint="A short blurb about you."
>
<Textarea
data-action="profile-bio"
value={profileDraft.bio}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, bio: e.target.value }))
}
rows={3}
placeholder="Tell others what you work on."
/>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Phone">
<Input
data-action="profile-phone"
value={profileDraft.phone}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, phone: e.target.value }))
}
placeholder="+61 …"
/>
</Field>
<Field label="Location">
<Input
data-action="profile-location"
value={profileDraft.location}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, location: e.target.value }))
}
placeholder="Melbourne, AU"
/>
</Field>
<Field
label="Timezone"
hint="IANA name (e.g. Australia/Melbourne)."
>
<Input
data-action="profile-timezone"
value={profileDraft.timezone}
onChange={(e) =>
setProfileDraft((d) => ({ ...d, timezone: e.target.value }))
}
placeholder="Australia/Melbourne"
/>
</Field>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="profile-arcadia-save"
onClick={saveArcadiaProfile}
disabled={!profileDirty || profileSaving}
>
{profileSaving ? "Saving…" : "Save profile"}
</Button>
<Button
data-action="profile-arcadia-revert"
variant="ghost"
onClick={() => {
if (!arcadiaProfile) return
setProfileDraft({
bio: arcadiaProfile.bio ?? "",
phone: arcadiaProfile.phone ?? "",
location: arcadiaProfile.location ?? "",
timezone: arcadiaProfile.timezone ?? "",
})
}}
disabled={!profileDirty || profileSaving}
>
Revert
</Button>
{profileSavedAt && !profileDirty ? (
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
<Check className="size-4" /> Saved.
</span>
) : null}
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Preferences</CardTitle> <CardTitle>Preferences</CardTitle>
<CardDescription> <CardDescription>
Avatar uploads land in your tenant's storage backend; the rest Avatar uploads land in your tenant's storage backend; the rest
(bio, signature, default persona) stays local to this browser. (title, signature, default persona) stays local to this browser.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-6"> <CardContent className="flex flex-col gap-6">
@@ -482,19 +631,6 @@ export default function ProfileRoute() {
</Field> </Field>
</div> </div>
<Field
label="Bio"
hint="A short blurb the assistant can reference (e.g. 'I work mostly in TypeScript')."
>
<Textarea
data-action="profile-bio"
value={prefs.bio}
onChange={(e) => setPrefs((d) => ({ ...d, bio: e.target.value }))}
rows={3}
placeholder="Tell the assistant about you."
/>
</Field>
<Field <Field
label="Signature" label="Signature"
hint="Appended automatically when you ask the assistant to draft an email or note." hint="Appended automatically when you ask the assistant to draft an email or note."