From 24f0392956ac2a2bc000269524ef5bb4f3eb8b57 Mon Sep 17 00:00:00 2001 From: khatibqa Date: Wed, 30 Sep 2026 01:53:15 -0400 Subject: [PATCH 01/10] Improve settings UX: grouped nav, toasts, confirms, provider cards, env panel - Group the settings sidebar (Account / Agents / Connections), add the Environment Variables link, drop the search box. - Add toast feedback and an in-app confirm dialog for destructive actions. - Collapse provider connect methods behind one disclosure with a connected summary; title the shared-workspaces billing switch. - Rebuild the environment variables card on the shadcn-style Card family. Co-Authored-By: Claude Sonnet 5.5 --- .../settings/personal/environment/page.tsx | 10 +- apps/web/app/settings/personal/layout.tsx | 5 +- .../components/settings/SettingsSidebar.tsx | 160 +++---- .../components/settings/confirm-dialog.tsx | 79 ++++ .../settings/environment-variables-panel.tsx | 421 +++++++++++------- .../settings/provider-account-card.test.tsx | 2 + .../settings/provider-account-card.tsx | 181 ++++++-- .../components/settings/settings-feedback.tsx | 121 +++++ apps/web/components/ui/label.tsx | 16 + 9 files changed, 697 insertions(+), 298 deletions(-) create mode 100644 apps/web/components/settings/confirm-dialog.tsx create mode 100644 apps/web/components/settings/settings-feedback.tsx create mode 100644 apps/web/components/ui/label.tsx diff --git a/apps/web/app/settings/personal/environment/page.tsx b/apps/web/app/settings/personal/environment/page.tsx index 62af269db..c88967c2e 100644 --- a/apps/web/app/settings/personal/environment/page.tsx +++ b/apps/web/app/settings/personal/environment/page.tsx @@ -2,9 +2,7 @@ import { EnvironmentVariablesPanel } from "@/components/settings/environment-var import { OrcaPageHeader, OrcaPageShell, - OrcaSubsectionHeader, } from "@/components/settings/orca-style"; -import { Card } from "@/components/ui/card"; import { listUserEnvironmentVariables } from "@/lib/providers/user-environment"; import { requireUser } from "@/lib/auth/session"; @@ -18,13 +16,7 @@ export default async function PersonalEnvironmentPage() { description="Store encrypted key/value pairs for your personal CoDev workflows." title="Environment Variables" /> - - - - + ); } diff --git a/apps/web/app/settings/personal/layout.tsx b/apps/web/app/settings/personal/layout.tsx index cc53a226a..8b257d529 100644 --- a/apps/web/app/settings/personal/layout.tsx +++ b/apps/web/app/settings/personal/layout.tsx @@ -1,6 +1,7 @@ import "../orca-theme.css"; import { AppChrome } from "@/components/shell/app-chrome"; +import { SettingsFeedbackProvider } from "@/components/settings/settings-feedback"; import { SettingsSidebar } from "@/components/settings/SettingsSidebar"; import { requireUser } from "@/lib/auth/session"; @@ -15,7 +16,9 @@ export default async function PersonalSettingsLayout({
-
{children}
+
+ {children} +
); diff --git a/apps/web/components/settings/SettingsSidebar.tsx b/apps/web/components/settings/SettingsSidebar.tsx index 0332a75c0..c81a42115 100644 --- a/apps/web/components/settings/SettingsSidebar.tsx +++ b/apps/web/components/settings/SettingsSidebar.tsx @@ -2,9 +2,8 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useState } from "react"; import type { LucideIcon } from "lucide-react"; -import { ArrowLeft, Blocks, Plug, Search, User } from "lucide-react"; +import { ArrowLeft, Blocks, KeyRound, Plug, User } from "lucide-react"; import { cn } from "@/lib/platform/utils"; @@ -12,48 +11,48 @@ type SettingsNavItem = { name: string; href: string; icon: LucideIcon; - badge?: string; - keywords?: string[]; }; -const personalNav: SettingsNavItem[] = [ - { name: "Profile", href: "/settings/personal/profile", icon: User }, +type SettingsNavGroup = { label: string; items: SettingsNavItem[] }; + +// Grouped by what the member is trying to do, not by page count: who they are, +// what runs their agents, and what those agents can reach. +const navGroups: SettingsNavGroup[] = [ { - name: "AI Provider Accounts", - href: "/settings/personal/providers", - icon: Plug, - badge: "Optional", - keywords: [ - "openai", - "anthropic", - "api key", - "codex", - "claude", - "cursor", - "connect", - "sign in", + label: "Account", + items: [ + { name: "Profile", href: "/settings/personal/profile", icon: User }, ], }, { - name: "Integrations", - href: "/settings/personal/integrations", - icon: Blocks, - keywords: ["github", "gitlab", "linear", "jira"], + label: "Agents", + items: [ + { + name: "AI Provider Accounts", + href: "/settings/personal/providers", + icon: Plug, + }, + { + name: "Environment Variables", + href: "/settings/personal/environment", + icon: KeyRound, + }, + ], + }, + { + label: "Connections", + items: [ + { + name: "Integrations", + href: "/settings/personal/integrations", + icon: Blocks, + }, + ], }, ]; -function matchesQuery(item: SettingsNavItem, query: string): boolean { - if (!query) return true; - const haystack = [item.name, ...(item.keywords ?? [])] - .join(" ") - .toLowerCase(); - return haystack.includes(query.toLowerCase()); -} - export function SettingsSidebar() { const pathname = usePathname(); - const [query, setQuery] = useState(""); - const visibleNav = personalNav.filter((item) => matchesQuery(item, query)); return ( ); } diff --git a/apps/web/components/settings/confirm-dialog.tsx b/apps/web/components/settings/confirm-dialog.tsx new file mode 100644 index 000000000..4403ec3cf --- /dev/null +++ b/apps/web/components/settings/confirm-dialog.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useId, useRef, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; + +/** + * In-app replacement for `window.confirm` on destructive settings actions. + * Focus starts on Cancel so an accidental Enter is safe; Escape and a click on + * the scrim both cancel. + */ +export function ConfirmDialog({ + title, + children, + confirmLabel, + busy = false, + onConfirm, + onCancel, +}: { + title: string; + children: ReactNode; + confirmLabel: string; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + const titleId = useId(); + const cancelRef = useRef(null); + + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + cancelRef.current?.focus(); + return () => opener?.focus?.(); + }, []); + + return ( +
{ + if (event.target === event.currentTarget) onCancel(); + }} + onKeyDown={(event) => { + if (event.key === "Escape") onCancel(); + }} + > +
+
+

+ {title} +

+
{children}
+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/components/settings/environment-variables-panel.tsx b/apps/web/components/settings/environment-variables-panel.tsx index cfa048b57..ed7db90a8 100644 --- a/apps/web/components/settings/environment-variables-panel.tsx +++ b/apps/web/components/settings/environment-variables-panel.tsx @@ -2,11 +2,21 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; -import { Pencil, Plus, Trash2 } from "lucide-react"; +import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react"; import type { EnvironmentVariable } from "@codev/contracts"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/settings/confirm-dialog"; +import { useSettingsNotify } from "@/components/settings/settings-feedback"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; function maskedValue(lastFour: string | null) { if (!lastFour) return "••••••••"; @@ -29,8 +39,17 @@ export function EnvironmentVariablesPanel({ text: string; } | null>(null); const [busy, setBusy] = useState(false); + const [deleting, setDeleting] = useState(null); + const notify = useSettingsNotify(); const [showAdd, setShowAdd] = useState(false); + // A toast where the settings area provides one; the inline line otherwise. + function report(next: { tone: "success" | "warning"; text: string }) { + if (notify) + notify(next.text, next.tone === "warning" ? "error" : "success"); + else setMessage(next); + } + function refreshList(next: EnvironmentVariable[]) { setVariables(next); router.refresh(); @@ -49,7 +68,7 @@ export function EnvironmentVariablesPanel({ error?: string; }; if (!response.ok || !payload.variable) { - setMessage({ + report({ tone: "warning", text: payload.error ?? "Could not save the variable.", }); @@ -64,7 +83,7 @@ export function EnvironmentVariablesPanel({ setName(""); setValue(""); setShowAdd(false); - setMessage({ tone: "success", text: `${payload.variable.name} saved.` }); + report({ tone: "success", text: `${payload.variable.name} saved.` }); setBusy(false); } @@ -81,7 +100,7 @@ export function EnvironmentVariablesPanel({ error?: string; }; if (!response.ok || !payload.variable) { - setMessage({ + report({ tone: "warning", text: payload.error ?? "Could not update the variable.", }); @@ -95,12 +114,11 @@ export function EnvironmentVariablesPanel({ ); setEditingId(null); setEditValue(""); - setMessage({ tone: "success", text: `${payload.variable.name} updated.` }); + report({ tone: "success", text: `${payload.variable.name} updated.` }); setBusy(false); } async function removeVariable(variable: EnvironmentVariable) { - if (!window.confirm(`Delete ${variable.name}?`)) return; setBusy(true); setMessage(null); const response = await fetch(`/api/settings/environment/${variable.id}`, { @@ -110,7 +128,7 @@ export function EnvironmentVariablesPanel({ const payload = (await response.json().catch(() => null)) as { error?: string; } | null; - setMessage({ + report({ tone: "warning", text: payload?.error ?? "Could not delete the variable.", }); @@ -118,175 +136,252 @@ export function EnvironmentVariablesPanel({ return; } refreshList(variables.filter((item) => item.id !== variable.id)); - setMessage({ tone: "success", text: `${variable.name} deleted.` }); + report({ tone: "success", text: `${variable.name} deleted.` }); setBusy(false); } return ( -
-
-

- Values are encrypted at rest and never shown again after you save. Use - them like a personal .env for CoDev workflows. -

- -
- - {showAdd ? ( -
- - + + +
+ Personal .env + + Encrypted at rest and write-only: values are never shown again after + you save them. Available to your agents and sandboxes like a + personal .env. + +
+ {variables.length > 0 || showAdd ? ( -
- ) : null} + ) : null} + - {variables.length === 0 && !showAdd ? ( -
- No environment variables yet -

- Add keys your agents and sandboxes should be able to use later. -

-
- ) : ( -
    - {variables.map((variable) => { - const isEditing = editingId === variable.id; - return ( -
  • + {showAdd ? ( +
    { + event.preventDefault(); + if (!busy && name.trim() && value) void addVariable(); + }} + > +
    +
    + + + setName(event.target.value.toUpperCase()) + } + placeholder="DATABASE_URL" + spellCheck={false} + value={name} + /> +
    +
    + + setValue(event.target.value)} + placeholder="Sensitive value" + spellCheck={false} + type="password" + value={value} + /> +
    +
    +
    + + - - - ) : ( - <> - - - - )} -
    -
  • - ); - })} -
- )} + {busy ? "Saving…" : "Save variable"} + +
+ + ) : null} - {message ? ( -

+ + +

+

No variables yet

+

+ Add the keys and settings your agents and sandboxes need, such + as DATABASE_URL. +

+
+ + + ) : null} + + {variables.length > 0 ? ( +
    + {variables.map((variable) => { + const isEditing = editingId === variable.id; + return ( +
  • +
    + + {variable.name} + + {isEditing ? ( + setEditValue(event.target.value)} + placeholder="Enter a new value" + spellCheck={false} + type="password" + value={editValue} + /> + ) : ( + + {maskedValue(variable.lastFour)} + + )} +
    +
    + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
    +
  • + ); + })} +
+ ) : null} + + {message ? ( +

+ {message.text} +

+ ) : null} + + + {deleting ? ( + setDeleting(null)} + onConfirm={() => { + const target = deleting; + setDeleting(null); + void removeVariable(target); + }} + title={`Delete ${deleting.name}?`} > - {message.text} -

+ Agents and sandboxes lose access to this value. This cannot be undone. +
) : null} - + ); } diff --git a/apps/web/components/settings/provider-account-card.test.tsx b/apps/web/components/settings/provider-account-card.test.tsx index afe4dd855..184558d95 100644 --- a/apps/web/components/settings/provider-account-card.test.tsx +++ b/apps/web/components/settings/provider-account-card.test.tsx @@ -534,6 +534,7 @@ describe("ProviderAccountCard", () => { ); fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); + fireEvent.click(screen.getByRole("button", { name: "Disconnect Cursor" })); expect(fetchMock).toHaveBeenCalledWith( "/api/personal/subscriptions?provider=cursor", @@ -749,6 +750,7 @@ describe("ProviderAccountCard", () => { expect(screen.getByText(/ending wxyz/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + fireEvent.click(screen.getByRole("button", { name: "Revoke login" })); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( "/api/personal/connections?provider=anthropic&kind=claude_cli_token", diff --git a/apps/web/components/settings/provider-account-card.tsx b/apps/web/components/settings/provider-account-card.tsx index 3bc98441b..5c3821721 100644 --- a/apps/web/components/settings/provider-account-card.tsx +++ b/apps/web/components/settings/provider-account-card.tsx @@ -12,6 +12,8 @@ import { Check, ChevronDown, Copy, KeyRound, Terminal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { ConfirmDialog } from "@/components/settings/confirm-dialog"; +import { useSettingsNotify } from "@/components/settings/settings-feedback"; import { Input } from "@/components/ui/input"; import { ClaudeHostedConnect } from "@/components/settings/claude-hosted-connect"; import { CodexHostedConnect } from "@/components/settings/codex-hosted-connect"; @@ -40,7 +42,7 @@ function CopyableCommand({ command }: { command: string }) { const [copied, setCopied] = useState(false); return ( -
+
$ {command} @@ -82,9 +84,7 @@ function SurfaceToggle({

{label}

- {note ? ( -

{note}

- ) : null} + {note ?

{note}

: null}
) : null} -
- {true ? ( + {anythingConnected ? ( +

+ + {connectedVia.join(" · ")} +

+ ) : null} + + {/* One primary action sits in the header (or the hosted flow above); + every other method lives behind a single disclosure so a member sees + one way in, not three. */} +
+ + {anythingConnected + ? "Manage connection" + : hasPrimaryAction + ? "Other ways to connect" + : "Choose how to connect"} + + + { void revoke()} + onClick={() => setConfirming("revoke-key")} size="sm" type="button" variant="secondary" @@ -701,7 +760,7 @@ export function ProviderAccountCard({ you save them.

- ) : null} + } {subscription.command ? (
- {runsInAWorkspace ? ( + {runsInAWorkspace ? ( +
+

+ Billing in shared workspaces +

- ) : null} -
+
+ ) : null} - {message ? ( -

- {message} + {inlineMessage ? ( +

+ {inlineMessage.text}

) : null} + + {confirming === "disconnect" ? ( + setConfirming(null)} + onConfirm={() => { + setConfirming(null); + void disconnect(); + }} + title={`Disconnect ${label}?`} + > + Agents will stop running on this {label} login until you connect it + again. Any API key you saved stays in place. + + ) : null} + {confirming === "revoke-key" ? ( + setConfirming(null)} + onConfirm={() => { + setConfirming(null); + void revoke(); + }} + title={`Revoke the ${label} API key?`} + > + The saved key is deleted from CoDev. You will need to paste it again + to use it. + + ) : null} + {confirming === "revoke-cli" ? ( + setConfirming(null)} + onConfirm={() => { + setConfirming(null); + void revokeClaudeCliToken(); + }} + title={`Revoke the ${label} CLI login?`} + > + Run the CLI command again to reconnect. + + ) : null} ); } diff --git a/apps/web/components/settings/settings-feedback.tsx b/apps/web/components/settings/settings-feedback.tsx new file mode 100644 index 000000000..014ddd4c1 --- /dev/null +++ b/apps/web/components/settings/settings-feedback.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { AlertCircle, CheckCircle2, X } from "lucide-react"; + +import { cn } from "@/lib/platform/utils"; + +type Tone = "success" | "error"; +type Toast = { id: number; tone: Tone; text: string }; + +const DISMISS_MS = 5000; + +const FeedbackContext = createContext< + ((text: string, tone?: Tone) => void) | null +>(null); + +/** + * Toast notifier for the settings area, or `null` outside a + * `SettingsFeedbackProvider`. Callers that can render without the provider + * (unit tests, stories) fall back to an inline message. + */ +export function useSettingsNotify() { + return useContext(FeedbackContext); +} + +/** + * Result messages used to render as 11px grey text at the bottom of whichever + * card was clicked, well out of view on a long page. A toast lands in one + * fixed place regardless of scroll position and announces politely to screen + * readers without stealing focus. + */ +export function SettingsFeedbackProvider({ + children, +}: { + children: ReactNode; +}) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + const timers = useRef(new Map>()); + + const dismiss = useCallback((id: number) => { + const timer = timers.current.get(id); + if (timer) clearTimeout(timer); + timers.current.delete(id); + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const notify = useCallback( + (text: string, tone: Tone = "success") => { + const id = (nextId.current += 1); + setToasts((current) => [...current.slice(-2), { id, tone, text }]); + timers.current.set( + id, + setTimeout(() => dismiss(id), DISMISS_MS), + ); + }, + [dismiss], + ); + + useEffect(() => { + const active = timers.current; + return () => { + for (const timer of active.values()) clearTimeout(timer); + }; + }, []); + + const value = useMemo(() => notify, [notify]); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ {toast.tone === "error" ? ( + + ) : ( + + )} +

{toast.text}

+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/components/ui/label.tsx b/apps/web/components/ui/label.tsx new file mode 100644 index 000000000..33310724b --- /dev/null +++ b/apps/web/components/ui/label.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; + +import { cn } from "@/lib/platform/utils"; + +export function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +