From d8a54437a2a9e67100a5022629b91c2da4932e0a Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 22 Aug 2026 19:55:39 -0400 Subject: [PATCH 01/17] feat(opencode): expand profile endpoint with role, description, github, custom_link fields Add role, description, github, and custom_link (url + label) to the profile data model. saveProfile accepts the new fields via query params, profileBody includes them in the GET response, and the HTTP route passes them through. Part of #231 --- .../opencode/src/server/amicode/profile.ts | 21 +++- .../server/routes/instance/httpapi/server.ts | 5 + .../test/server/amicode-profile.test.ts | 106 ++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/amicode-profile.test.ts diff --git a/packages/opencode/src/server/amicode/profile.ts b/packages/opencode/src/server/amicode/profile.ts index 023b5a70f..daec92f94 100644 --- a/packages/opencode/src/server/amicode/profile.ts +++ b/packages/opencode/src/server/amicode/profile.ts @@ -263,6 +263,11 @@ export function saveProfile(fields: { focus?: string scholar?: string affiliation_logo?: string + role?: string + description?: string + github?: string + custom_link_url?: string + custom_link_label?: string }): string { const fp = profileFile() let current: any = {} @@ -274,7 +279,7 @@ export function saveProfile(fields: { // A JSON primitive ("x", 42) survives the ?? {} and current[key]= would // throw a 500 the client swallows โ€” only an object merge makes sense. if (typeof current !== "object" || current === null || Array.isArray(current)) current = {} - for (const key of ["name", "affiliation", "focus", "scholar", "affiliation_logo"] as const) { + for (const key of ["name", "affiliation", "focus", "scholar", "affiliation_logo", "role", "description", "github"] as const) { const v = fields[key] if (typeof v === "string") { const t = v.trim() @@ -282,6 +287,13 @@ export function saveProfile(fields: { else current[key] = t } } + // custom_link is a compound field: url + label stored as an object + if (typeof fields.custom_link_url === "string") { + const url = fields.custom_link_url.trim() + const label = (typeof fields.custom_link_label === "string" ? fields.custom_link_label.trim() : "") || "" + if (url === "") delete current.custom_link + else current.custom_link = { url, label } + } mkdirSync(path.dirname(fp), { recursive: true }) writeFileSync(fp, JSON.stringify(current, null, 2) + "\n") cache = undefined // next profileResponse() re-reads @@ -313,6 +325,13 @@ export function profileBody(input: { affiliation_logo: typeof profile.affiliation_logo === "string" ? profile.affiliation_logo : null, focus: typeof profile.focus === "string" ? profile.focus : null, avatar: typeof profile.avatar === "string" ? profile.avatar : null, + role: typeof profile.role === "string" ? profile.role : null, + description: typeof profile.description === "string" ? profile.description : null, + github: typeof profile.github === "string" ? profile.github : null, + custom_link: + profile.custom_link && typeof profile.custom_link === "object" && typeof profile.custom_link.url === "string" + ? { url: profile.custom_link.url, label: typeof profile.custom_link.label === "string" ? profile.custom_link.label : "" } + : null, platforms: mix.map((p) => p.key), stats: { problems: countProblems(input.problemsRoot), diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 23f7941ee..489d370d9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -356,6 +356,11 @@ const amicodeProblemsRoute = HttpRouter.use((router) => focus: field("focus"), scholar: field("scholar"), affiliation_logo: field("affiliation_logo"), + role: field("role"), + description: field("description"), + github: field("github"), + custom_link_url: field("custom_link_url"), + custom_link_label: field("custom_link_label"), }) return HttpServerResponse.text(body, { contentType: "application/json" }) }), diff --git a/packages/opencode/test/server/amicode-profile.test.ts b/packages/opencode/test/server/amicode-profile.test.ts new file mode 100644 index 000000000..6f5fec9bb --- /dev/null +++ b/packages/opencode/test/server/amicode-profile.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" + +describe("profile endpoint โ€” new fields", () => { + let dir: string + let savedEnv: Record + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "amicode-profile-")) + savedEnv = { + AMICODE_PROFILE_FILE: process.env.AMICODE_PROFILE_FILE, + AMICODE_PROBLEMS_ROOT: process.env.AMICODE_PROBLEMS_ROOT, + AMICODE_RUNS_ROOT: process.env.AMICODE_RUNS_ROOT, + } + process.env.AMICODE_PROFILE_FILE = path.join(dir, "profile.json") + process.env.AMICODE_PROBLEMS_ROOT = path.join(dir, "problems") + process.env.AMICODE_RUNS_ROOT = path.join(dir, "runs") + mkdirSync(path.join(dir, "problems")) + mkdirSync(path.join(dir, "runs")) + }) + + afterEach(() => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + rmSync(dir, { recursive: true, force: true }) + }) + + test("saveProfile accepts and persists role and description", async () => { + const { saveProfile } = await import("../../src/server/amicode/profile") + // Save with the new fields + const result = JSON.parse( + saveProfile({ + name: "JJ Lee", + role: "Head of Optimization", + description: "Quantum control researcher focused on high-fidelity gates", + }), + ) + expect(result.ok).toBe(true) + expect(result.you.name).toBe("JJ Lee") + expect(result.you.role).toBe("Head of Optimization") + expect(result.you.description).toBe("Quantum control researcher focused on high-fidelity gates") + + // Verify persisted to file + const stored = JSON.parse(readFileSync(path.join(dir, "profile.json"), "utf8")) + expect(stored.role).toBe("Head of Optimization") + expect(stored.description).toBe("Quantum control researcher focused on high-fidelity gates") + }) + + test("saveProfile accepts github and custom_link fields", async () => { + const { saveProfile } = await import("../../src/server/amicode/profile") + const result = JSON.parse( + saveProfile({ + name: "JJ Lee", + github: "https://github.com/jjlee", + custom_link_url: "https://harmoniqs.co", + custom_link_label: "Lab page", + }), + ) + expect(result.ok).toBe(true) + expect(result.you.github).toBe("https://github.com/jjlee") + expect(result.you.custom_link).toEqual({ url: "https://harmoniqs.co", label: "Lab page" }) + }) + + test("profileBody includes new fields in response", async () => { + const { profileBody } = await import("../../src/server/amicode/profile") + const profileFile = path.join(dir, "profile.json") + writeFileSync( + profileFile, + JSON.stringify({ + name: "Test User", + role: "Postdoc", + description: "Working on bosonic codes", + github: "https://github.com/testuser", + custom_link: { url: "https://example.com", label: "My site" }, + }), + ) + const result = JSON.parse( + profileBody({ + profileFile, + mountsFile: path.join(dir, "mounts.toml"), + problemsRoot: path.join(dir, "problems"), + runsRoot: path.join(dir, "runs"), + memoryDirs: [], + }), + ) + expect(result.ok).toBe(true) + expect(result.you.role).toBe("Postdoc") + expect(result.you.description).toBe("Working on bosonic codes") + expect(result.you.github).toBe("https://github.com/testuser") + expect(result.you.custom_link).toEqual({ url: "https://example.com", label: "My site" }) + }) + + test("saveProfile clears empty string fields", async () => { + const { saveProfile } = await import("../../src/server/amicode/profile") + // First set values + saveProfile({ name: "Test", role: "Postdoc", description: "Bio text" }) + // Then clear them + const result = JSON.parse(saveProfile({ role: "", description: "" })) + expect(result.you.role).toBe(null) + expect(result.you.description).toBe(null) + }) +}) From a415f8f57f7321b3c5a7c8cd90d384fbc39f88fd Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 22 Aug 2026 20:01:37 -0400 Subject: [PATCH 02/17] feat(app): replace Home button with profile dropdown popover Remove the Home/Dashboard page route (redirect / to /new-session instead). Replace the titlebar Home button (grid-plus icon, mod+b) with a profile popover button (person icon, Kobalte Popover, chrome-dropdown coordination). The popover shows a compact identity card (avatar initials, name, role, affiliation, research area, bio) with link pills (Scholar, GitHub, Custom) and inline editing. Empty state auto-enters edit mode. Widget UI files are kept (used by in-chat preview in message-timeline). Server-side widget infrastructure is unchanged. Part of #231 --- packages/app/src/app.tsx | 3 +- .../app/src/components/profile-popover.tsx | 433 ++++++++++++++++++ packages/app/src/components/titlebar.tsx | 33 +- packages/app/src/i18n/en.ts | 1 + packages/ui/src/v2/components/icon.tsx | 4 + 5 files changed, 443 insertions(+), 31 deletions(-) create mode 100644 packages/app/src/components/profile-popover.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 72568c490..af1a5b30a 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -74,7 +74,6 @@ import { bugDockController } from "@/pages/session/composer/bug-dock-controller" import { postBugReportPoke } from "@/utils/amicode-bug-report" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome } from "@/pages/home" import { LegacyHome } from "@/pages/home/legacy-home" import { AmicodeFileRefBridge } from "@/components/amicode-file-ref-bridge" import { DevToolsReopenBridge } from "@/components/settings-dialog" @@ -741,7 +740,7 @@ function Routes(props: { serverScoped?: JSX.Element }) { - + } /> diff --git a/packages/app/src/components/profile-popover.tsx b/packages/app/src/components/profile-popover.tsx new file mode 100644 index 000000000..6091b746a --- /dev/null +++ b/packages/app/src/components/profile-popover.tsx @@ -0,0 +1,433 @@ +import { batch, createEffect, createSignal, Show } from "solid-js" +import { Popover } from "@opencode-ai/ui/popover" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { announceChromeDropdown, chromeDropdownOpenId, clearChromeDropdown } from "@/utils/chrome-dropdown" + +interface ProfileData { + name: string + role: string | null + affiliation: string | null + affiliation_logo: string | null + focus: string | null + description: string | null + github: string | null + scholar: string | null + custom_link: { url: string; label: string } | null +} + +export function ProfilePopoverTrigger() { + const [shown, setShownRaw] = createSignal(false) + const [profile, setProfile] = createSignal(null) + const [editing, setEditing] = createSignal(false) + const [draft, setDraft] = createSignal({ + name: "", + role: "", + affiliation: "", + focus: "", + description: "", + scholar: "", + github: "", + custom_link_url: "", + custom_link_label: "", + }) + + const setShown = (next: boolean) => { + batch(() => { + if (next) announceChromeDropdown("profile") + else clearChromeDropdown("profile") + setShownRaw(next) + }) + } + + createEffect(() => { + if (chromeDropdownOpenId() !== "profile" && shown()) setShownRaw(false) + }) + + const fetchProfile = async () => { + try { + const res = await fetch("/amicode/profile") + const data = await res.json() + if (data.ok && data.you) setProfile(data.you) + } catch { + /* silent */ + } + } + + createEffect(() => { + if (shown() && !profile()) void fetchProfile() + }) + + const beginEdit = () => { + const p = profile() + setDraft({ + name: p?.name ?? "", + role: p?.role ?? "", + affiliation: p?.affiliation ?? "", + focus: p?.focus ?? "", + description: p?.description ?? "", + scholar: p?.scholar ?? "", + github: p?.github ?? "", + custom_link_url: p?.custom_link?.url ?? "", + custom_link_label: p?.custom_link?.label ?? "", + }) + setEditing(true) + } + + const save = async () => { + const d = draft() + const params = new URLSearchParams() + for (const [k, v] of Object.entries(d)) params.set(k, v) + try { + const res = await fetch(`/amicode/profile?${params.toString()}`, { method: "POST" }) + const data = await res.json() + if (data.ok && data.you) setProfile(data.you) + setEditing(false) + } catch { + /* silent */ + } + } + + const initials = () => { + const p = profile() + if (!p?.name) return "" + const parts = p.name.trim().split(/\s+/) + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + return parts[0][0]?.toUpperCase() ?? "" + } + + const isEmpty = () => !profile() || (!profile()!.name || profile()!.name === "Practitioner") + + const openExternal = (url: string) => { + window.open(url, "_blank", "noreferrer") + } + + return ( + } + class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-lg" + gutter={8} + placement="bottom-start" + > + +
+ setEditing(false)} isEmpty={isEmpty()} beginEdit={beginEdit} />} + > + + +
+
+
+ ) +} + +function ReadView(props: { + profile: ProfileData + initials: string + onEdit: () => void + onOpenExternal: (url: string) => void +}) { + const [logoBroken, setLogoBroken] = createSignal(false) + + return ( +
+ {/* Header: avatar + name/role/affiliation */} +
+ {/* Avatar */} +
+ }> + {props.initials} + +
+ {/* Name + Role + Affiliation */} +
+
+
+ {props.profile.name} +
+ +
+ +
+ {props.profile.role} +
+
+ +
+ {props.profile.affiliation} +
+
+
+
+ + {/* Focus + Description */} + +
+ +
+ {props.profile.focus} +
+
+ +
+ {props.profile.description} +
+
+
+
+ + {/* Link pills */} +
+ + + +
+
+ ) +} + +function LinkPill(props: { icon: string; url: string | null; tooltip: string; onOpen: (url: string) => void }) { + const filled = () => !!props.url + return ( + + ) +} + +function EditForm(props: { + draft: () => Record + setDraft: (d: Record) => void + onSave: () => void + onCancel: () => void + isEmpty: boolean + beginEdit: () => void +}) { + // Auto-enter edit mode for empty state + if (props.isEmpty) { + props.beginEdit() + } + + const update = (key: string, value: string) => props.setDraft({ ...props.draft(), [key]: value }) + + return ( +
+ update("name", e.currentTarget.value)} + autofocus={props.isEmpty} + style={{ "font-size": "14px", "font-weight": "600" }} + /> + update("role", e.currentTarget.value)} + /> + update("affiliation", e.currentTarget.value)} + /> + update("focus", e.currentTarget.value)} + /> +