diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 72568c490..fb0ff3337 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -59,7 +59,7 @@ import { setPendingAutoSend } from "@/pages/new-session/new-session-draft-contro import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider, useSettings } from "@/context/settings" -import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" +import { TabsProvider, tabHref, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" @@ -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 }) { - + @@ -750,6 +749,40 @@ function Routes(props: { serverScoped?: JSX.Element }) { ) } +/** Landing route when the Home/Dashboard page is removed: creates a new draft + * session tab on mount and navigates to it. If a session tab already exists, + * navigates to the most recent one instead of creating a duplicate. */ +function NewSessionLanding() { + const tabs = useTabs() + const global = useGlobal() + const navigate = useNavigate() + + const land = () => { + // If there's already a session or draft tab, navigate to it + const existing = tabs.store.find((tab) => tab.type === "session" || tab.type === "draft") + if (existing) { + navigate(tabHref(existing), { replace: true }) + return + } + + // Otherwise create a new draft — find a server + project to use + const connections = global.servers.list() + const conn = connections[0] + if (!conn) return // no server connected yet — will re-render when one connects + + const project = global.ensureServerCtx(conn).projects.list()[0] + if (!project) return // no project yet + + tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") + } + + return ( + + {(() => { land(); return null })()} + + ) +} + function NewLayoutLegacySessionRedirect() { const server = useServer() const tabs = useTabs() diff --git a/packages/app/src/components/profile-popover.tsx b/packages/app/src/components/profile-popover.tsx new file mode 100644 index 000000000..9901cc46e --- /dev/null +++ b/packages/app/src/components/profile-popover.tsx @@ -0,0 +1,603 @@ +import { batch, createEffect, createSignal, Show, type ComponentProps, type JSX } from "solid-js" +import { Popover } from "@opencode-ai/ui/popover" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { announceChromeDropdown, chromeDropdownOpenId, clearChromeDropdown } from "@/utils/chrome-dropdown" +import { useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" + +interface ProfileData { + name: string + role: string | null + affiliation: string | null + affiliation_logo: string | null + focus: string | null + description: string | null + avatar: 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 server = useServer() + const platform = usePlatform() + + 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 data = await amicodeGet(server.current, "/amicode/profile") as any + if (data.ok && data.you) setProfile(data.you) + } catch { + /* silent */ + } + } + + createEffect(() => { + if (shown()) 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 data = await amicodePost(server.current, `/amicode/profile?${params.toString()}`) as any + 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 = () => { + const p = profile() + if (!p) return true + // Show edit mode only when there's truly nothing set (no name beyond default, no role, no affiliation) + return (!p.name || p.name === "Practitioner") && !p.role && !p.affiliation && !p.focus + } + + const openExternal = (url: string) => { + platform.openExternal(url) + } + + const saveAvatar = async (dataUrl: string) => { + // Resize to 96x96 to keep profile.json small + const resized = await resizeImage(dataUrl, 96) + try { + const data = await amicodePost(server.current, "/amicode/profile", { avatar: resized }) as any + if (data.ok && data.you) setProfile(data.you) + } catch { + /* silent */ + } + } + + const removeAvatar = async () => { + try { + const data = await amicodePost(server.current, "/amicode/profile", { avatar: "" }) as any + if (data.ok && data.you) setProfile(data.you) + } catch { + /* silent */ + } + } + + return ( + + } + trigger={} + 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)} onAvatarChange={saveAvatar} onAvatarRemove={removeAvatar} currentAvatar={profile()?.avatar ?? null} isEmpty={isEmpty()} />} + > + + +
+
+
+ ) +} + +function ReadView(props: { + profile: ProfileData + initials: string + onEdit: () => void + onOpenExternal: (url: string) => void +}) { + const [logoBroken, setLogoBroken] = createSignal(false) + const [bioExpanded, setBioExpanded] = createSignal(false) + + return ( +
+ {/* Header: avatar + name/role/affiliation */} +
+ {/* Avatar */} +
+ }> + {props.initials} + + }> + Profile + +
+ {/* Name + Role + Affiliation */} +
+
+
+ {props.profile.name} +
+ +
+ +
+ {props.profile.role && props.profile.affiliation + ? `${props.profile.role} @ ${props.profile.affiliation}` + : props.profile.role || props.profile.affiliation} +
+
+
+
+ + {/* Focus + Description */} + +
+ +
+ {props.profile.focus} +
+
+ +
setBioExpanded(!bioExpanded())} + style={{ + "font-size": "12px", + color: "var(--v2-text-text-muted)", + "margin-top": "4px", + cursor: "pointer", + }} + > +
+ {props.profile.description} +
+
+
+
+
+ + {/* Link pills */} +
+ } + url={props.profile.scholar} + tooltip="Google Scholar" + onOpen={props.onOpenExternal} + /> + } + url={props.profile.github} + tooltip="GitHub" + onOpen={props.onOpenExternal} + /> + } + url={props.profile.custom_link?.url ?? null} + tooltip={props.profile.custom_link?.label || "Custom link"} + onOpen={props.onOpenExternal} + /> +
+
+ ) +} + +function LinkPill(props: { icon: JSX.Element; 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 + onAvatarChange: (dataUrl: string) => void + onAvatarRemove: () => void + currentAvatar: string | null + isEmpty: boolean +}) { + const update = (key: string, value: string) => props.setDraft({ ...props.draft(), [key]: value }) + let fileInput: HTMLInputElement | undefined + + const handleFileSelect = (e: Event) => { + const input = e.currentTarget as HTMLInputElement + const file = input.files?.[0] + if (!file) return + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = () => { + props.onAvatarChange(reader.result as string) + } + reader.readAsDataURL(file) + input.value = "" + } + + const avatarInitials = () => { + const name = props.draft().name + if (!name) return "" + const parts = name.trim().split(/\s+/) + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + return parts[0][0]?.toUpperCase() ?? "" + } + + return ( +
+ {/* Hidden file input */} + + {/* Clickable avatar tile */} +
+
fileInput?.click()} + title="Click to change photo" + style={{ + width: "48px", + height: "48px", + "border-radius": "10px", + display: "flex", + "align-items": "center", + "justify-content": "center", + overflow: "hidden", + background: props.currentAvatar ? "transparent" : "var(--accent, #fff676)", + color: "var(--accent-ink, #111214)", + "font-size": "16px", + "font-weight": "700", + cursor: "pointer", + border: "1px dashed var(--v2-border-border-base)", + }} + > + }> + {avatarInitials()} + + }> + Profile + +
+ + + +
+ 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)} + /> +