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 (
@@ -67,67 +66,42 @@ export function SettingsSidebar() {
-
-
-
- setQuery(event.target.value)}
- placeholder="Search settings"
- type="search"
- value={query}
- />
-
-
-
-
-
-
- Personal
-
- {visibleNav.length > 0 ? (
-
- {visibleNav.map((item) => {
- const isActive = pathname === item.href;
- const Icon = item.icon;
-
- return (
-
-
- {item.name}
- {item.badge ? (
-
- {item.badge}
-
- ) : null}
-
- );
- })}
-
- ) : (
-
- No matching settings.
+
+ {navGroups.map((group) => (
+
-
+ {group.items.map((item) => {
+ const isActive = pathname === item.href;
+ const Icon = item.icon;
+
+ return (
+
+
+
{item.name}
+
+ );
+ })}
+
+ ))}
+
);
}
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}
+
+
+
+ Cancel
+
+
+ {confirmLabel}
+
+
+
+
+ );
+}
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.
-
-
{
- setShowAdd((current) => !current);
- setMessage(null);
- }}
- size="sm"
- type="button"
- variant="outline"
- >
-
- {showAdd ? "Cancel" : "Add"}
-
-
-
- {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 ? (
+
+ ) : null}
- {message ? (
-
+
+
+
+
+
No variables yet
+
+ Add the keys and settings your agents and sandboxes need, such
+ as DATABASE_URL.
+
+
+ {
+ setShowAdd(true);
+ setMessage(null);
+ }}
+ size="sm"
+ type="button"
+ >
+
+ Add variable
+
+
+ ) : null}
+
+ {variables.length > 0 ? (
+
+ ) : 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}
+
{connected
? "Connected, but nothing here can run it yet"
@@ -156,14 +156,14 @@ function RunsIn({
? names[0]
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
return (
-
+
{`Runs in ${spoken}`}
Runs in
{surfaces.map((surface) => (
{SURFACE_LABEL[surface]}
@@ -194,10 +194,10 @@ function FallbackRow({
{title}
-
{description}
+
{description}
{connected ? (
-
+
Connected
@@ -244,7 +244,14 @@ export function ProviderAccountCard({
const [busy, setBusy] = useState<
"connect" | "disconnect" | "save" | "revoke" | ""
>("");
- const [message, setMessage] = useState("");
+ const notify = useSettingsNotify();
+ const [inlineMessage, setInlineMessage] = useState<{
+ text: string;
+ tone: "success" | "error";
+ } | null>(null);
+ const [confirming, setConfirming] = useState<
+ "disconnect" | "revoke-key" | "revoke-cli" | null
+ >(null);
const [flow, setFlow] = useState(null);
const [connected, setConnected] = useState(
subscription.status === "connected",
@@ -253,11 +260,26 @@ export function ProviderAccountCard({
const apiKeyLabel = `${connection.label} API key`;
const disabled = busy !== "";
+ // Results go to a toast where the settings area provides one, and fall back
+ // to an inline line otherwise. An empty string clears the inline line.
+ function report(text: string, tone: "success" | "error") {
+ if (notify) notify(text, tone);
+ else setInlineMessage({ text, tone });
+ }
+ function setMessage(text: string) {
+ if (!text) setInlineMessage(null);
+ else report(text, "success");
+ }
+ function fail(text: string) {
+ report(text, "error");
+ }
+
// Cursor is the only provider left with a browser sign-in: the tab does
// the signing in and CoDev learns about it only by polling its own
// callback. Claude and Codex connect through the rows below instead.
const pollTimer = useRef | null>(null);
const pollStartedAt = useRef(0);
+ const stallNoticeShown = useRef(false);
const isCursor = subscription.provider === "cursor";
const showClaudeConnect =
hostedClaudeConnect && subscription.provider === "claude";
@@ -270,6 +292,16 @@ export function ProviderAccountCard({
runsIn.includes("workspace") || runsIn.includes("gen2");
const anythingConnected =
connected || cliTokenConnected || apiKeyState.status === "connected";
+ // Cursor's header button and the hosted Claude/Codex flow are the one
+ // primary way in; without either, the methods below are all there is.
+ const hasPrimaryAction = isCursor || showHostedConnect;
+ const connectedVia = [
+ connected ? "Signed in with a subscription" : null,
+ cliTokenConnected ? "Signed in from the CLI" : null,
+ apiKeyState.status === "connected"
+ ? `API key ending ${apiKeyState.lastFour}`
+ : null,
+ ].filter((item): item is string => item !== null);
useEffect(
() => () => {
if (pollTimer.current) clearInterval(pollTimer.current);
@@ -307,7 +339,7 @@ export function ProviderAccountCard({
stopPolling();
setFlow(null);
setBusy("");
- setMessage(payload.error ?? `${label} sign-in failed. Start again.`);
+ fail(payload.error ?? `${label} sign-in failed. Start again.`);
return;
}
if (payload.status === "connected") {
@@ -327,8 +359,10 @@ export function ProviderAccountCard({
if (
isCursor &&
pollStartedAt.current > 0 &&
+ !stallNoticeShown.current &&
Date.now() - pollStartedAt.current > 90_000
) {
+ stallNoticeShown.current = true;
setMessage(
"Still waiting on Cursor. If you already finished signing in, connect with an API key below instead.",
);
@@ -353,7 +387,7 @@ export function ProviderAccountCard({
};
if (!response.ok) {
setBusy("");
- setMessage(payload.error ?? `${label} sign-in could not start.`);
+ fail(payload.error ?? `${label} sign-in could not start.`);
return;
}
@@ -361,13 +395,14 @@ export function ProviderAccountCard({
window.open(payload.loginUrl, "_blank", "noopener,noreferrer");
setFlow({ kind: "polling", loginUrl: payload.loginUrl });
pollStartedAt.current = Date.now();
+ stallNoticeShown.current = false;
void poll();
pollTimer.current = setInterval(() => void poll(), 2000);
return;
}
setBusy("");
- setMessage(`${label} returned an unexpected sign-in response.`);
+ fail(`${label} returned an unexpected sign-in response.`);
}
function cancelFlow() {
@@ -386,7 +421,7 @@ export function ProviderAccountCard({
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
- setMessage(payload?.error ?? "The account could not be disconnected.");
+ fail(payload?.error ?? "The account could not be disconnected.");
return;
}
setConnected(false);
@@ -414,7 +449,7 @@ export function ProviderAccountCard({
error?: string;
} | null;
if (!response.ok) {
- setMessage(payload?.error ?? "The Cursor API key was not accepted.");
+ fail(payload?.error ?? "The Cursor API key was not accepted.");
return;
}
setDraft("");
@@ -429,7 +464,7 @@ export function ProviderAccountCard({
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
- setMessage(payload?.error ?? "The key could not be saved.");
+ fail(payload?.error ?? "The key could not be saved.");
return;
}
const next = (payload.connections as ProviderConnectionRecord[]).find(
@@ -453,7 +488,7 @@ export function ProviderAccountCard({
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
- setMessage(payload?.error ?? "The key could not be revoked.");
+ fail(payload?.error ?? "The key could not be revoked.");
return;
}
const next = (payload.connections as ProviderConnectionRecord[]).find(
@@ -496,7 +531,7 @@ export function ProviderAccountCard({
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
- setMessage(payload?.error ?? "The setting could not be saved.");
+ fail(payload?.error ?? "The setting could not be saved.");
return;
}
setMessage(
@@ -520,7 +555,7 @@ export function ProviderAccountCard({
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
- setMessage(payload?.error ?? "The CLI login could not be revoked.");
+ fail(payload?.error ?? "The CLI login could not be revoked.");
return;
}
setMessage(`${label} CLI login revoked.`);
@@ -554,7 +589,7 @@ export function ProviderAccountCard({
void disconnect()}
+ onClick={() => setConfirming("disconnect")}
size="sm"
type="button"
variant="outline"
@@ -576,7 +611,7 @@ export function ProviderAccountCard({
) : connected ? (
void disconnect()}
+ onClick={() => setConfirming("disconnect")}
size="sm"
type="button"
variant="outline"
@@ -625,8 +660,32 @@ export function ProviderAccountCard({
) : 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 ? (
void revokeClaudeCliToken()}
+ onClick={() => setConfirming("revoke-cli")}
size="sm"
type="button"
variant="outline"
@@ -738,8 +797,13 @@ export function ProviderAccountCard({
) : null}
+
- {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}
+
dismiss(toast.id)}
+ type="button"
+ >
+
+
+
+ ))}
+
+
+ );
+}
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 (
+
+ );
+}
From 25d6221ae8cc908f7274afe18a42ed16c25dad8e Mon Sep 17 00:00:00 2001
From: khatibqa
Date: Wed, 30 Sep 2026 01:59:42 -0400
Subject: [PATCH 02/10] Settings: one GitHub home, editable profile, .env
import
- GitHub is linked once on Profile; Integrations now only manages repository
access and links back to Profile when GitHub is not linked.
- Profile: editable display name, change password (requires the current
password), and a downloadable account-data export (no secrets).
- Refresh the session token's name after a display-name edit.
- Environment variables: paste-a-.env import, live name validation, and a
note on where variables are used.
Co-Authored-By: Claude Sonnet 5.5
---
apps/web/app/actions/profile.ts | 85 ++++++++
apps/web/app/api/settings/export/route.ts | 66 ++++++
.../settings/personal/integrations/page.tsx | 29 +--
.../app/settings/personal/profile/page.tsx | 139 +++++++++++--
apps/web/auth.ts | 12 +-
.../settings/environment-variables-panel.tsx | 190 +++++++++++++++---
.../components/settings/parse-dotenv.test.ts | 51 +++++
apps/web/components/settings/parse-dotenv.ts | 63 ++++++
.../components/settings/set-password-form.tsx | 30 ++-
9 files changed, 601 insertions(+), 64 deletions(-)
create mode 100644 apps/web/app/actions/profile.ts
create mode 100644 apps/web/app/api/settings/export/route.ts
create mode 100644 apps/web/components/settings/parse-dotenv.test.ts
create mode 100644 apps/web/components/settings/parse-dotenv.ts
diff --git a/apps/web/app/actions/profile.ts b/apps/web/app/actions/profile.ts
new file mode 100644
index 000000000..5db034b14
--- /dev/null
+++ b/apps/web/app/actions/profile.ts
@@ -0,0 +1,85 @@
+"use server";
+
+import { eq } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+
+import { schema } from "@codev/db";
+
+import { unstable_update } from "@/auth";
+import { getNewAccountPasswordError } from "@/lib/auth/password-policy";
+import { requireUser } from "@/lib/auth/session";
+import { hashPassword, verifyPassword } from "@/lib/platform/crypto";
+import { getDatabase } from "@/lib/platform/database";
+
+const PROFILE_PATH = "/settings/personal/profile";
+const MAX_DISPLAY_NAME_LENGTH = 80;
+
+export async function updateDisplayName(formData: FormData) {
+ const user = await requireUser();
+ const name = String(formData.get("name") ?? "")
+ .trim()
+ .replace(/\s+/g, " ");
+
+ if (!name || name.length > MAX_DISPLAY_NAME_LENGTH) {
+ redirect(`${PROFILE_PATH}?error=name`);
+ }
+
+ await getDatabase()
+ .update(schema.users)
+ .set({ name, updatedAt: new Date() })
+ .where(eq(schema.users.id, user.id));
+
+ // The session token carries the name shown in the app shell. Refreshing it
+ // is best effort: the database is already correct, and the token catches up
+ // at the next sign-in if this fails.
+ try {
+ await unstable_update({ user: { name } });
+ } catch {
+ // Intentionally ignored.
+ }
+
+ revalidatePath("/settings", "layout");
+ redirect(`${PROFILE_PATH}?name=saved`);
+}
+
+/**
+ * Changes the password of an account that already has one. Unlike
+ * `setAccountPassword`, which only ever fills an empty hash, this requires the
+ * current password, so a hijacked session alone cannot overwrite it.
+ */
+export async function changeAccountPassword(
+ redirectTo: string,
+ formData: FormData,
+) {
+ const user = await requireUser();
+ const current = String(formData.get("current") ?? "");
+ const password = String(formData.get("password") ?? "");
+ const confirm = String(formData.get("confirm") ?? "");
+
+ const [row] = await getDatabase()
+ .select({ passwordHash: schema.users.passwordHash })
+ .from(schema.users)
+ .where(eq(schema.users.id, user.id))
+ .limit(1);
+
+ if (!row?.passwordHash) {
+ redirect(`${redirectTo}?error=nopassword`);
+ }
+ if (!(await verifyPassword(current, row.passwordHash))) {
+ redirect(`${redirectTo}?error=current`);
+ }
+ if (password !== confirm) {
+ redirect(`${redirectTo}?error=match`);
+ }
+ if (getNewAccountPasswordError(password)) {
+ redirect(`${redirectTo}?error=policy`);
+ }
+
+ await getDatabase()
+ .update(schema.users)
+ .set({ passwordHash: await hashPassword(password), updatedAt: new Date() })
+ .where(eq(schema.users.id, user.id));
+
+ redirect(`${redirectTo}?password=changed`);
+}
diff --git a/apps/web/app/api/settings/export/route.ts b/apps/web/app/api/settings/export/route.ts
new file mode 100644
index 000000000..7891b8aae
--- /dev/null
+++ b/apps/web/app/api/settings/export/route.ts
@@ -0,0 +1,66 @@
+import { eq } from "drizzle-orm";
+
+import { schema } from "@codev/db";
+
+import { getConnectedAccounts } from "@/lib/auth/identity";
+import { apiError, getApiUser } from "@/lib/http/api";
+import { getDatabase } from "@/lib/platform/database";
+import { listUserEnvironmentVariables } from "@/lib/providers/user-environment";
+
+/**
+ * A copy of the account facts a member can see in Settings. Secrets are never
+ * included: environment variables are listed by name only, and provider
+ * credentials are not exported at all.
+ */
+export async function GET() {
+ const user = await getApiUser();
+ if (!user) return apiError(new Error("Authentication required."), 401);
+
+ try {
+ const [row] = await getDatabase()
+ .select({
+ name: schema.users.name,
+ email: schema.users.email,
+ login: schema.users.login,
+ createdAt: schema.users.createdAt,
+ })
+ .from(schema.users)
+ .where(eq(schema.users.id, user.id))
+ .limit(1);
+ const accounts = await getConnectedAccounts(user.id);
+ const variables = await listUserEnvironmentVariables(user.id);
+
+ const body = {
+ exportedAt: new Date().toISOString(),
+ profile: {
+ id: user.id,
+ name: row?.name ?? null,
+ email: row?.email ?? null,
+ login: row?.login ?? null,
+ createdAt: row?.createdAt?.toISOString() ?? null,
+ },
+ signInMethods: {
+ google: accounts.google.connected,
+ github: accounts.github.connected
+ ? { login: accounts.github.login ?? null }
+ : false,
+ password: accounts.hasPassword,
+ },
+ environmentVariables: variables.map((variable) => ({
+ name: variable.name,
+ createdAt: variable.createdAt,
+ updatedAt: variable.updatedAt,
+ })),
+ };
+
+ return new Response(JSON.stringify(body, null, 2), {
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Disposition": 'attachment; filename="codev-account.json"',
+ "Cache-Control": "no-store",
+ },
+ });
+ } catch (error) {
+ return apiError(error);
+ }
+}
diff --git a/apps/web/app/settings/personal/integrations/page.tsx b/apps/web/app/settings/personal/integrations/page.tsx
index 8312235e0..b0209fec1 100644
--- a/apps/web/app/settings/personal/integrations/page.tsx
+++ b/apps/web/app/settings/personal/integrations/page.tsx
@@ -2,8 +2,7 @@ import { Settings } from "lucide-react";
import { isGitHubAuthConfigured } from "@codev/config";
-import { connectGitHubAccount } from "@/app/actions/github";
-import { Button, LinkButton } from "@/components/ui/button";
+import { LinkButton } from "@/components/ui/button";
import { GithubMark } from "@/components/settings/github-mark";
import {
IntegrationsList,
@@ -16,15 +15,17 @@ import {
import { getConnectedAccounts } from "@/lib/auth/identity";
import { requireUser } from "@/lib/auth/session";
+/**
+ * GitHub is linked once, as a sign-in method on Profile. This page owns what
+ * happens after that — which repositories the CoDev GitHub App can reach — so
+ * it links back to Profile instead of offering a second "Connect" button that
+ * did the same thing.
+ */
export default async function PersonalIntegrationsPage() {
const user = await requireUser();
const connectedAccounts = await getConnectedAccounts(user.id);
const github = connectedAccounts.github;
- const connectAction = connectGitHubAccount.bind(
- null,
- "/settings/personal/integrations",
- );
const installUrl = process.env.GITHUB_APP_SLUG
? `https://github.com/apps/${process.env.GITHUB_APP_SLUG}/installations/new`
: "https://github.com/settings/installations";
@@ -39,7 +40,7 @@ export default async function PersonalIntegrationsPage() {
? github.login
? `Connected · @${github.login}`
: "Connected"
- : "Not connected",
+ : "Not linked. Link your GitHub sign-in on Profile first.",
action: isGitHubAuthConfigured() ? (
github.connected ? (
- Configure
+ Manage repository access
) : (
-
+
+ Link on Profile
+
)
) : null,
},
diff --git a/apps/web/app/settings/personal/profile/page.tsx b/apps/web/app/settings/personal/profile/page.tsx
index 0737e2a17..0c5c62f6f 100644
--- a/apps/web/app/settings/personal/profile/page.tsx
+++ b/apps/web/app/settings/personal/profile/page.tsx
@@ -1,8 +1,10 @@
import { isGitHubAuthConfigured } from "@codev/config";
-import { KeyRound, Mail } from "lucide-react";
+import { Download, KeyRound, Mail } from "lucide-react";
import { connectGitHubAccount } from "@/app/actions/github";
-import { Button } from "@/components/ui/button";
+import { updateDisplayName } from "@/app/actions/profile";
+import { Button, LinkButton } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { GithubMark } from "@/components/settings/github-mark";
import { GoogleMark } from "@/components/settings/google-mark";
import {
@@ -20,8 +22,19 @@ const passwordErrorCopy: Record = {
match: "Those passwords did not match. Try again.",
policy: "Choose a stronger password that meets every requirement below.",
exists: "This account already has a password set.",
+ current: "That is not your current password.",
+ nopassword: "This account has no password to change yet.",
};
+// `error` is shared by the name and password forms; each shows only its own.
+const PASSWORD_ERRORS = new Set([
+ "match",
+ "policy",
+ "exists",
+ "current",
+ "nopassword",
+]);
+
function initials(
name: string | null | undefined,
email: string | null | undefined,
@@ -81,7 +94,12 @@ function SignInMethodRow({
export default async function PersonalProfilePage({
searchParams,
}: {
- searchParams: Promise<{ github?: string; password?: string; error?: string }>;
+ searchParams: Promise<{
+ github?: string;
+ password?: string;
+ name?: string;
+ error?: string;
+ }>;
}) {
const user = await requireUser();
const connectedAccounts = await getConnectedAccounts(user.id);
@@ -90,7 +108,14 @@ export default async function PersonalProfilePage({
params.github === "connected" && connectedAccounts.github.connected;
const passwordJustSet =
params.password === "set" && connectedAccounts.hasPassword;
- const passwordError = params.error ? passwordErrorCopy[params.error] : null;
+ const passwordJustChanged =
+ params.password === "changed" && connectedAccounts.hasPassword;
+ const passwordError =
+ params.error && PASSWORD_ERRORS.has(params.error)
+ ? passwordErrorCopy[params.error]
+ : null;
+ const nameError =
+ params.error === "name" ? "Enter a name up to 80 characters long." : null;
return (
@@ -99,19 +124,51 @@ export default async function PersonalProfilePage({
title="Profile"
/>
-
-
- {initials(user.name, user.email)}
-
-
-
- {user.name || "Unnamed"}
-
-
-
- {user.email || "No email on file"}
-
+
+
+
+ {initials(user.name, user.email)}
+
+
+
+ {user.name || "Unnamed"}
+
+
+
+ {user.email || "No email on file"}
+
+
+
@@ -135,8 +192,15 @@ export default async function PersonalProfilePage({
/>
+ Repository access
+
+ ) : isGitHubAuthConfigured() ? (
- {variables.length > 0 || showAdd ? (
- {
- setShowAdd((current) => !current);
- setMessage(null);
- }}
- size="sm"
- type="button"
- variant={showAdd ? "secondary" : "outline"}
- >
- {showAdd ? (
- "Cancel"
- ) : (
- <>
-
- Add variable
- >
- )}
-
+ {variables.length > 0 || showAdd || showImport ? (
+
+
{
+ setShowImport((current) => !current);
+ setShowAdd(false);
+ setMessage(null);
+ }}
+ size="sm"
+ type="button"
+ variant={showImport ? "secondary" : "outline"}
+ >
+
+ Paste .env
+
+
{
+ setShowAdd((current) => !current);
+ setShowImport(false);
+ setMessage(null);
+ }}
+ size="sm"
+ type="button"
+ variant={showAdd ? "secondary" : "outline"}
+ >
+
+ Add variable
+
+
) : null}
@@ -181,7 +249,8 @@ export function EnvironmentVariablesPanel({
className="space-y-4 rounded-lg border border-border bg-muted/30 p-4"
onSubmit={(event) => {
event.preventDefault();
- if (!busy && name.trim() && value) void addVariable();
+ if (!busy && name.trim() && value && !nameError)
+ void addVariable();
}}
>
@@ -191,6 +260,8 @@ export function EnvironmentVariablesPanel({
autoComplete="off"
autoFocus
className="font-mono"
+ aria-describedby={nameError ? "env-name-error" : undefined}
+ aria-invalid={nameError ? true : undefined}
id="env-name"
onChange={(event) =>
setName(event.target.value.toUpperCase())
@@ -199,6 +270,15 @@ export function EnvironmentVariablesPanel({
spellCheck={false}
value={name}
/>
+ {nameError ? (
+
+ {nameError}
+
+ ) : null}
Value
@@ -225,7 +305,7 @@ export function EnvironmentVariablesPanel({
Cancel
@@ -235,7 +315,53 @@ export function EnvironmentVariablesPanel({
) : null}
- {variables.length === 0 && !showAdd ? (
+ {showImport ? (
+
+ ) : null}
+
+ {variables.length === 0 && !showAdd && !showImport ? (
@@ -258,6 +384,16 @@ export function EnvironmentVariablesPanel({
Add variable
+ {
+ setShowImport(true);
+ setMessage(null);
+ }}
+ type="button"
+ >
+ or paste a .env file
+
) : null}
diff --git a/apps/web/components/settings/parse-dotenv.test.ts b/apps/web/components/settings/parse-dotenv.test.ts
new file mode 100644
index 000000000..6cdc6b53e
--- /dev/null
+++ b/apps/web/components/settings/parse-dotenv.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+
+import { environmentNameError, parseDotenv } from "./parse-dotenv";
+
+describe("parseDotenv", () => {
+ it("reads assignments, skipping blanks and comments", () => {
+ const result = parseDotenv(
+ [
+ "# database",
+ "",
+ "DATABASE_URL=postgres://x",
+ "export API_KEY='abc'",
+ ].join("\n"),
+ );
+ expect(result.entries).toEqual([
+ { name: "DATABASE_URL", value: "postgres://x" },
+ { name: "API_KEY", value: "abc" },
+ ]);
+ expect(result.skipped).toEqual([]);
+ });
+
+ it("strips inline comments from unquoted values only", () => {
+ const result = parseDotenv('A=one # note\nB="two # kept"');
+ expect(result.entries).toEqual([
+ { name: "A", value: "one" },
+ { name: "B", value: "two # kept" },
+ ]);
+ });
+
+ it("lets a later duplicate win and reports unusable lines", () => {
+ const result = parseDotenv("A=1\nnot an assignment\n1BAD=x\nEMPTY=\nA=2");
+ expect(result.entries).toEqual([{ name: "A", value: "2" }]);
+ expect(result.skipped).toEqual([2, 3, 4]);
+ });
+
+ it("handles CRLF line endings", () => {
+ expect(parseDotenv("A=1\r\nB=2\r\n").entries).toHaveLength(2);
+ });
+});
+
+describe("environmentNameError", () => {
+ it("accepts valid names and blank input", () => {
+ expect(environmentNameError("DATABASE_URL")).toBeNull();
+ expect(environmentNameError("")).toBeNull();
+ });
+
+ it("explains why a name is rejected", () => {
+ expect(environmentNameError("1ABC")).toMatch(/number/);
+ expect(environmentNameError("not valid")).toMatch(/underscores/);
+ });
+});
diff --git a/apps/web/components/settings/parse-dotenv.ts b/apps/web/components/settings/parse-dotenv.ts
new file mode 100644
index 000000000..918378c2a
--- /dev/null
+++ b/apps/web/components/settings/parse-dotenv.ts
@@ -0,0 +1,63 @@
+const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+export const ENV_NAME_HINT = "Use letters, numbers, and underscores.";
+
+/** `null` when the name is acceptable, otherwise a message for the member. */
+export function environmentNameError(name: string): string | null {
+ const trimmed = name.trim();
+ if (!trimmed) return null;
+ if (trimmed.length > 128) return "Names can be at most 128 characters.";
+ if (/^[0-9]/.test(trimmed)) return "A name cannot start with a number.";
+ return NAME_PATTERN.test(trimmed) ? null : ENV_NAME_HINT;
+}
+
+export type ParsedDotenv = {
+ entries: { name: string; value: string }[];
+ /** 1-based line numbers that looked like assignments but could not be used. */
+ skipped: number[];
+};
+
+function unquote(raw: string): string {
+ const value = raw.trim();
+ const quote = value[0];
+ if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
+ const inner = value.slice(1, -1);
+ return quote === '"' ? inner.replace(/\\n/g, "\n") : inner;
+ }
+ // An unquoted value ends at an inline ` #` comment.
+ const comment = value.search(/\s#/);
+ return (comment === -1 ? value : value.slice(0, comment)).trim();
+}
+
+/**
+ * Reads pasted `.env` text. Later duplicates win, blank lines and `#` comments
+ * are ignored, and a line that cannot be used is reported rather than dropped
+ * silently so the member knows what was not imported.
+ */
+export function parseDotenv(text: string): ParsedDotenv {
+ const byName = new Map();
+ const skipped: number[] = [];
+
+ text.split(/\r?\n/).forEach((line, index) => {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) return;
+ const assignment = trimmed.replace(/^export\s+/, "");
+ const equals = assignment.indexOf("=");
+ if (equals === -1) {
+ skipped.push(index + 1);
+ return;
+ }
+ const name = assignment.slice(0, equals).trim();
+ const value = unquote(assignment.slice(equals + 1));
+ if (environmentNameError(name) !== null || !name || !value) {
+ skipped.push(index + 1);
+ return;
+ }
+ byName.set(name, value);
+ });
+
+ return {
+ entries: [...byName].map(([name, value]) => ({ name, value })),
+ skipped,
+ };
+}
diff --git a/apps/web/components/settings/set-password-form.tsx b/apps/web/components/settings/set-password-form.tsx
index 63b0d6d9a..01ac41d1c 100644
--- a/apps/web/components/settings/set-password-form.tsx
+++ b/apps/web/components/settings/set-password-form.tsx
@@ -3,19 +3,43 @@
import { useState } from "react";
import { Check, Circle } from "lucide-react";
+import { changeAccountPassword } from "@/app/actions/profile";
import { setAccountPassword } from "@/app/actions/set-password";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getNewAccountPasswordRequirements } from "@/lib/auth/password-policy";
import { cn } from "@/lib/platform/utils";
-export function SetPasswordForm({ redirectTo }: { redirectTo: string }) {
+export function SetPasswordForm({
+ redirectTo,
+ mode = "set",
+}: {
+ redirectTo: string;
+ /** `change` asks for the current password first and updates the existing one. */
+ mode?: "set" | "change";
+}) {
const [password, setPassword] = useState("");
const requirements = getNewAccountPasswordRequirements(password);
- const action = setAccountPassword.bind(null, redirectTo);
+ const action =
+ mode === "change"
+ ? changeAccountPassword.bind(null, redirectTo)
+ : setAccountPassword.bind(null, redirectTo);
return (
+ {mode === "change" ? (
+
+
+ Current password
+
+
+
+ ) : null}
@@ -64,7 +88,7 @@ export function SetPasswordForm({ redirectTo }: { redirectTo: string }) {
))}
- Set password
+ {mode === "change" ? "Change password" : "Set password"}
);
From ef392ccd047ba314b3ce34fff659e1618dafa479 Mon Sep 17 00:00:00 2001
From: khatibqa
Date: Wed, 30 Sep 2026 02:06:42 -0400
Subject: [PATCH 03/10] Add a hide/show toggle for the app sidebar and pin the
settings nav
- AppSidebarFrame lets members collapse the app sidebar to a slim rail; the
choice is remembered in localStorage (safe when storage is blocked).
- The settings sidebar is now sticky to the scroll container, so it no longer
scrolls away with long pages such as Profile.
Co-Authored-By: Claude Sonnet 5.5
---
apps/web/app/app-theme.css | 68 +++++++++++++++
.../components/settings/SettingsSidebar.tsx | 2 +-
apps/web/components/shell/app-chrome.tsx | 42 +++++-----
.../components/shell/app-sidebar-frame.tsx | 82 +++++++++++++++++++
4 files changed, 173 insertions(+), 21 deletions(-)
create mode 100644 apps/web/components/shell/app-sidebar-frame.tsx
diff --git a/apps/web/app/app-theme.css b/apps/web/app/app-theme.css
index aa0e30b77..387f12991 100644
--- a/apps/web/app/app-theme.css
+++ b/apps/web/app/app-theme.css
@@ -502,6 +502,61 @@
position: relative;
}
+/* Hide/show control. Expanded, it floats over the sidebar header's right edge;
+ collapsed, the rail becomes a slim column that keeps it reachable. */
+.app-sidebar-rail {
+ position: fixed;
+ top: 14px;
+ left: 176px;
+ z-index: 5;
+}
+
+.app-sidebar-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ opacity: 0.7;
+ transition:
+ background-color 150ms ease,
+ opacity 150ms ease;
+}
+
+.app-sidebar-toggle:hover {
+ background: rgba(var(--ink-rgb), 0.08);
+ opacity: 1;
+}
+
+.app-sidebar-toggle:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+ opacity: 1;
+}
+
+.app-with-sidebar.is-sidebar-collapsed {
+ grid-template-columns: 44px minmax(0, 1fr);
+}
+
+.app-with-sidebar.is-sidebar-collapsed .app-sidebar {
+ display: none;
+}
+
+.app-with-sidebar.is-sidebar-collapsed .app-sidebar-rail {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ height: 100dvh;
+ padding: 14px 6px;
+ border-right: 1px solid var(--line);
+ background: rgba(var(--paper-rgb), 0.82);
+}
+
.dashboard-shell {
min-height: 100dvh;
padding: clamp(32px, 5vw, 64px) clamp(20px, 5vw, 64px) 96px;
@@ -794,6 +849,19 @@
grid-template-rows: auto auto;
}
+ /* The rail is a desktop affordance; the mobile bar is always shown. */
+ .app-sidebar-rail {
+ display: none;
+ }
+
+ .app-with-sidebar.is-sidebar-collapsed {
+ grid-template-columns: 1fr;
+ }
+
+ .app-with-sidebar.is-sidebar-collapsed .app-sidebar {
+ display: flex;
+ }
+
.app-sidebar {
flex-direction: row;
align-items: center;
diff --git a/apps/web/components/settings/SettingsSidebar.tsx b/apps/web/components/settings/SettingsSidebar.tsx
index c81a42115..935498b1d 100644
--- a/apps/web/components/settings/SettingsSidebar.tsx
+++ b/apps/web/components/settings/SettingsSidebar.tsx
@@ -55,7 +55,7 @@ export function SettingsSidebar() {
const pathname = usePathname();
return (
-
+
+ }
+ >
+ {children}
+
+
);
}
diff --git a/apps/web/components/shell/app-sidebar-frame.tsx b/apps/web/components/shell/app-sidebar-frame.tsx
new file mode 100644
index 000000000..f5b2f3259
--- /dev/null
+++ b/apps/web/components/shell/app-sidebar-frame.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useSyncExternalStore, type ReactNode } from "react";
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
+
+const STORAGE_KEY = "codev.app-sidebar.collapsed";
+const SIDEBAR_ID = "app-sidebar";
+
+const listeners = new Set<() => void>();
+// Used when storage is blocked, so the toggle still works for this page view.
+let memoryCollapsed = false;
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ window.addEventListener("storage", listener);
+ return () => {
+ listeners.delete(listener);
+ window.removeEventListener("storage", listener);
+ };
+}
+
+function getSnapshot() {
+ try {
+ return window.localStorage.getItem(STORAGE_KEY) === "1";
+ } catch {
+ return memoryCollapsed;
+ }
+}
+
+function setCollapsedPreference(next: boolean) {
+ memoryCollapsed = next;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
+ } catch {
+ // Not persisted; the in-memory value still applies.
+ }
+ listeners.forEach((listener) => listener());
+}
+
+/**
+ * Lays out the app sidebar and its content, and lets the member hide the
+ * sidebar to give the page the full width. The choice is remembered per
+ * browser. The sidebar itself stays server-rendered and is passed in; a hidden
+ * sidebar is removed from the layout (`display: none`), so it cannot be
+ * reached by keyboard while collapsed.
+ */
+export function AppSidebarFrame({
+ sidebar,
+ children,
+}: {
+ sidebar: ReactNode;
+ children: ReactNode;
+}) {
+ // The server always renders the sidebar open; the stored choice applies
+ // after hydration.
+ const collapsed = useSyncExternalStore(subscribe, getSnapshot, () => false);
+ const toggle = () => setCollapsedPreference(!collapsed);
+
+ const Icon = collapsed ? PanelLeftOpen : PanelLeftClose;
+
+ return (
+
+ {sidebar}
+
+
+
+
+
+
{children}
+
+ );
+}
From f1df2ed22329871095dc6bfc83dcc37e692b8be3 Mon Sep 17 00:00:00 2001
From: khatibqa
Date: Wed, 30 Sep 2026 02:10:19 -0400
Subject: [PATCH 04/10] Keep the collapsed-sidebar toggle visible and drop the
reserved gap
Co-Authored-By: Claude Sonnet 5.5
---
apps/web/app/app-theme.css | 24 ++++++++++++-------
.../components/settings/SettingsSidebar.tsx | 3 ++-
2 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/apps/web/app/app-theme.css b/apps/web/app/app-theme.css
index 387f12991..d2c82af02 100644
--- a/apps/web/app/app-theme.css
+++ b/apps/web/app/app-theme.css
@@ -508,7 +508,7 @@
position: fixed;
top: 14px;
left: 176px;
- z-index: 5;
+ z-index: 30;
}
.app-sidebar-toggle {
@@ -539,8 +539,11 @@
opacity: 1;
}
+/* Collapsed: no column is reserved. The toggle floats over the top-left corner
+ with its own surface so it is visible on any page; pages that put content
+ there (the settings nav) offset themselves via the collapsed class. */
.app-with-sidebar.is-sidebar-collapsed {
- grid-template-columns: 44px minmax(0, 1fr);
+ grid-template-columns: minmax(0, 1fr);
}
.app-with-sidebar.is-sidebar-collapsed .app-sidebar {
@@ -548,13 +551,16 @@
}
.app-with-sidebar.is-sidebar-collapsed .app-sidebar-rail {
- position: sticky;
- top: 0;
- z-index: 1;
- height: 100dvh;
- padding: 14px 6px;
- border-right: 1px solid var(--line);
- background: rgba(var(--paper-rgb), 0.82);
+ top: 10px;
+ left: 10px;
+}
+
+.app-with-sidebar.is-sidebar-collapsed .app-sidebar-toggle {
+ border: 1px solid var(--line);
+ background: rgba(var(--paper-rgb), 0.92);
+ color: var(--ink);
+ opacity: 1;
+ backdrop-filter: blur(8px);
}
.dashboard-shell {
diff --git a/apps/web/components/settings/SettingsSidebar.tsx b/apps/web/components/settings/SettingsSidebar.tsx
index 935498b1d..b3e64f6ec 100644
--- a/apps/web/components/settings/SettingsSidebar.tsx
+++ b/apps/web/components/settings/SettingsSidebar.tsx
@@ -56,7 +56,8 @@ export function SettingsSidebar() {
return (