Files
multica/packages/views/settings/components/account-tab.tsx
Jiayuan Zhang 2ad1cd8ff8 feat(profile): user profile description injected into agent brief (MUL-2406)
## Summary

Adds per-user `profile_description` so coding agents have cheap, durable context about who is asking. v1 per the brief Xeon locked in on [MUL-2406](mention://issue/63a7247c-4f6a-42cf-90d1-7c746e77158a):

- **DB** — `user.profile_description TEXT NOT NULL DEFAULT ''` (migration 096). 2000-rune cap enforced server-side. No nullable / privacy state to manage.
- **API** — `PATCH /api/me` accepts the field; `UserResponse` always emits it. Client wraps `updateMe` in a lenient `UserSchema` + `EMPTY_USER` fallback per CLAUDE.md API Response Compatibility.
- **UI** — Settings → Account gains an "About you" textarea with live `n/2000` counter, `maxLength` guard, and a localized too-long error (EN + zh-Hans).
- **CLI** — `multica user profile get` / `multica user profile update` with `--description / --description-stdin / --description-file / --clear`, mirroring the existing `issue comment add` input-mode menu.
- **Daemon injection** — claim handler resolves the runtime owner and stamps `requesting_user_name` + `requesting_user_profile_description` on the task. `buildMetaSkillContent` emits `## Requesting User` between `## Agent Identity` and `## Available Commands`, blockquoted and framed as background context. The block is omitted entirely when the description is empty (no token cost when unused).

Brief is written **once per task** via `CLAUDE.md` / `AGENTS.md`, not the per-turn prompt — same path the agent already reads for identity, so no extra per-turn cost.

## Test plan

- [x] `go build ./...`, `go vet ./...`, `go test ./internal/cli/ ./internal/daemon/ ./internal/daemon/execenv/ ./cmd/multica/`
- [x] New brief tests: `TestBuildMetaSkillContentEmitsRequestingUser`, `TestBuildMetaSkillContentOmitsRequestingUserWhenEmpty`
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm test` (74 files, 644 tests pass)
- [ ] Handler DB tests (`TestUpdateMe*`) require a migrated test DB — not runnable in this sandbox
- [ ] Manual: open Settings → Account, set a description, confirm the next daemon-run agent's `CLAUDE.md` shows `## Requesting User`
2026-05-19 19:51:28 +02:00

183 lines
6.9 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Camera, Loader2, Save } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { toast } from "sonner";
import { useAuthStore } from "@multica/core/auth";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { useT } from "../../i18n";
// Mirror server/internal/handler/auth.go:MaxProfileDescriptionLen. Counted in
// JS String.length (UTF-16 code units) here while the server counts runes,
// so a profile full of supplementary-plane emoji will trip the client cap
// before the server's — which is the safer direction of drift.
const MAX_PROFILE_DESCRIPTION_LEN = 2000;
export function AccountTab() {
const { t } = useT("settings");
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [profileName, setProfileName] = useState(user?.name ?? "");
const [profileDescription, setProfileDescription] = useState(
user?.profile_description ?? "",
);
const [profileSaving, setProfileSaving] = useState(false);
const { upload, uploading } = useFileUpload(api);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setProfileName(user?.name ?? "");
setProfileDescription(user?.profile_description ?? "");
}, [user]);
const descriptionTooLong = profileDescription.length > MAX_PROFILE_DESCRIPTION_LEN;
const initials = (user?.name ?? "")
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected
e.target.value = "";
try {
const result = await upload(file);
if (!result) return;
const updated = await api.updateMe({ avatar_url: result.link });
setUser(updated);
toast.success(t(($) => $.account.toast_avatar_updated));
} catch (err) {
toast.error(err instanceof Error ? err.message : t(($) => $.account.toast_avatar_failed));
}
};
const handleProfileSave = async () => {
if (descriptionTooLong) return;
setProfileSaving(true);
try {
const updated = await api.updateMe({
name: profileName,
profile_description: profileDescription,
});
setUser(updated);
toast.success(t(($) => $.account.toast_profile_updated));
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.account.toast_profile_failed));
} finally {
setProfileSaving(false);
}
};
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold">{t(($) => $.account.section_profile)}</h2>
<Card>
<CardContent className="space-y-4">
{/* Avatar upload */}
<div className="flex items-center gap-4">
<button
type="button"
className="group relative h-16 w-16 shrink-0 rounded-full bg-muted overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{user?.avatar_url ? (
<img
src={user.avatar_url}
alt={user.name}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-lg font-semibold text-muted-foreground">
{initials}
</span>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : (
<Camera className="h-5 w-5 text-white" />
)}
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
<div className="text-xs text-muted-foreground">
{t(($) => $.account.click_avatar_hint)}
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t(($) => $.account.name_label)}</Label>
<Input
type="search"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
{t(($) => $.account.profile_description_label)}
</Label>
<Textarea
value={profileDescription}
onChange={(e) => setProfileDescription(e.target.value)}
placeholder={t(($) => $.account.profile_description_placeholder)}
rows={5}
maxLength={MAX_PROFILE_DESCRIPTION_LEN}
className="mt-1 resize-y"
/>
<div className="mt-1 flex items-start justify-between gap-3 text-xs text-muted-foreground">
<span>{t(($) => $.account.profile_description_hint)}</span>
<span
className={descriptionTooLong ? "text-destructive shrink-0" : "shrink-0"}
aria-live="polite"
>
{profileDescription.length}/{MAX_PROFILE_DESCRIPTION_LEN}
</span>
</div>
{descriptionTooLong ? (
<p className="mt-1 text-xs text-destructive">
{t(($) => $.account.profile_description_too_long, {
max: MAX_PROFILE_DESCRIPTION_LEN,
count: profileDescription.length,
})}
</p>
) : null}
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleProfileSave}
disabled={profileSaving || !profileName.trim() || descriptionTooLong}
>
<Save className="h-3 w-3" />
{profileSaving ? t(($) => $.account.saving) : t(($) => $.account.save)}
</Button>
</div>
</CardContent>
</Card>
</section>
</div>
);
}