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/app-theme.css b/apps/web/app/app-theme.css
index aa0e30b77..d2c82af02 100644
--- a/apps/web/app/app-theme.css
+++ b/apps/web/app/app-theme.css
@@ -502,6 +502,67 @@
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: 30;
+}
+
+.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;
+}
+
+/* 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: minmax(0, 1fr);
+}
+
+.app-with-sidebar.is-sidebar-collapsed .app-sidebar {
+ display: none;
+}
+
+.app-with-sidebar.is-sidebar-collapsed .app-sidebar-rail {
+ 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 {
min-height: 100dvh;
padding: clamp(32px, 5vw, 64px) clamp(20px, 5vw, 64px) 96px;
@@ -794,6 +855,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/app/gen2/gen2.css b/apps/web/app/gen2/gen2.css
index 25c1acd6b..2efa0c961 100644
--- a/apps/web/app/gen2/gen2.css
+++ b/apps/web/app/gen2/gen2.css
@@ -1,186 +1,10 @@
-.gen2-shell {
- width: min(960px, 100%);
- min-height: 100dvh;
- margin: 0 auto;
- padding: clamp(32px, 5vw, 64px) clamp(20px, 4vw, 48px) 96px;
-}
-
-.gen2-shell h1 {
- margin: 0;
- font-size: clamp(1.75rem, 3vw, 2.25rem);
- font-weight: 640;
- letter-spacing: -0.03em;
- line-height: 1.15;
-}
-
-.gen2-shell h2 {
- margin: 0 0 12px;
- font-size: 0.95rem;
- font-weight: 600;
-}
-
-.gen2-lede {
- margin: 12px 0 32px;
- max-width: 42rem;
- color: var(--muted);
- font-size: 1.05rem;
- line-height: 1.6;
-}
-
-.gen2-create,
-.gen2-share,
-.gen2-actions {
+.gen2-share {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
-.gen2-shell .primary-button,
-.gen2-shell .secondary-button,
-.gen2-card {
- cursor: pointer;
-}
-
-.gen2-field {
- display: flex;
- flex: 1 1 16rem;
- flex-direction: column;
- gap: 8px;
- color: var(--muted);
- font-size: 0.8125rem;
-}
-
-.gen2-field input {
- min-height: 46px;
- padding: 0 14px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--surface-2);
- color: var(--ink);
- font-size: 0.95rem;
-}
-
-.gen2-field input:focus-visible {
- outline: 2px solid var(--gold);
- outline-offset: 2px;
-}
-
-.gen2-list {
- display: grid;
- gap: 12px;
- margin: 40px 0 0;
- padding: 0;
- list-style: none;
-}
-
-.gen2-card-row {
- display: flex;
- align-items: stretch;
- gap: 8px;
-}
-
-.gen2-card-row .gen2-card {
- flex: 1;
- min-width: 0;
-}
-
-.gen2-card {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- min-height: 72px;
- padding: 20px 22px;
- border: 1px solid var(--line);
- border-radius: 16px;
- background: var(--surface-2);
- color: inherit;
- text-decoration: none;
- transition:
- transform 200ms ease,
- border-color 200ms ease;
-}
-
-.gen2-card:hover {
- transform: translateY(-2px);
- border-color: var(--line-strong);
-}
-
-.gen2-card-pending {
- cursor: default;
- opacity: 0.72;
-}
-
-.gen2-card:focus-visible {
- outline: 2px solid var(--gold);
- outline-offset: 3px;
-}
-
-.gen2-delete-button {
- display: inline-flex;
- flex: 0 0 48px;
- align-items: center;
- justify-content: center;
- min-height: 48px;
- margin-block: auto;
- padding: 0;
- border: 1px solid var(--line);
- border-radius: 14px;
- background: var(--surface-2);
- color: var(--muted);
- cursor: pointer;
- transition:
- color 160ms ease,
- border-color 160ms ease,
- opacity 160ms ease;
-}
-
-.gen2-delete-button:hover:not(:disabled) {
- border-color: #ef4444;
- color: #ef4444;
-}
-
-.gen2-delete-button:focus-visible {
- outline: 2px solid var(--gold);
- outline-offset: 2px;
-}
-
-.gen2-delete-button:disabled {
- cursor: wait;
- opacity: 0.55;
-}
-
-.gen2-delete-spinner {
- animation: gen2-delete-spin 900ms linear infinite;
-}
-
-.gen2-action-error {
- margin: 8px 0 0;
- color: #ffb3a6;
- font-size: 0.875rem;
- line-height: 1.5;
-}
-
-@keyframes gen2-delete-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-.gen2-empty {
- margin: 40px 0 0;
- color: var(--muted);
-}
-
-.gen2-create-limit {
- flex-basis: 100%;
- margin: 0;
- color: var(--muted);
- font-size: 0.875rem;
- line-height: 1.5;
-}
-
.gen2-room-header {
display: flex;
flex-wrap: wrap;
@@ -310,10 +134,6 @@
opacity: 0.62;
}
-.gen2-chat-thread .gen2-empty {
- margin: 0 0 16px;
-}
-
.gen2-agent-log {
min-height: 200px;
max-height: min(52vh, 440px);
@@ -333,10 +153,6 @@
list-style: none;
}
-.gen2-agent-log .gen2-empty {
- margin: 0;
-}
-
.gen2-agent-composer-hero {
margin-top: 16px;
}
@@ -377,29 +193,6 @@
margin-top: 16px;
}
-.gen2-field textarea {
- min-height: 92px;
- padding: 12px 14px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--surface-2);
- color: var(--ink);
- font: inherit;
- font-size: 0.95rem;
- line-height: 1.5;
- resize: vertical;
-}
-
-.gen2-field textarea:focus-visible {
- outline: 2px solid var(--gold);
- outline-offset: 2px;
-}
-
-.gen2-field textarea:disabled {
- cursor: not-allowed;
- opacity: 0.62;
-}
-
.gen2-agent-composer-actions {
display: flex;
justify-content: flex-end;
@@ -453,10 +246,6 @@
transition: none;
}
- .gen2-delete-spinner {
- animation: none;
- }
-
.gen2-card:hover,
.primary-button:hover {
transform: none;
@@ -472,144 +261,3 @@
max-height: 30vh;
}
}
-
-@media (max-width: 520px) {
- .gen2-card {
- flex-wrap: wrap;
- justify-content: flex-start;
- gap: 4px 12px;
- }
-
- .gen2-card > strong {
- flex: 1 0 100%;
- }
-
- .gen2-card-repo {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .gen2-card .gen2-status {
- min-height: 32px;
- }
-}
-
-/* --------------------------------------------- create from a repository */
-
-.gen2-create-row {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 10px;
-}
-
-.gen2-create-row .primary-button,
-.gen2-create-row .secondary-button {
- display: inline-flex;
- align-items: center;
- gap: 6px;
-}
-
-.gen2-create-select {
- min-height: 40px;
- padding: 0 10px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--surface-2);
- color: var(--ink);
- font-size: 0.8125rem;
-}
-
-.gen2-create-search {
- display: flex;
- flex: 1 1 18rem;
- align-items: center;
- gap: 8px;
- padding: 0 12px;
- min-height: 40px;
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--surface-2);
- color: var(--muted);
-}
-
-.gen2-create-search input {
- flex: 1;
- border: 0;
- background: none;
- color: var(--ink);
- font: inherit;
- font-size: 0.875rem;
- outline: none;
-}
-
-.gen2-create-select:focus-visible,
-.gen2-create-search:focus-within {
- outline: 2px solid var(--gold);
- outline-offset: 1px;
-}
-
-.gen2-repo-list {
- display: grid;
- gap: 2px;
- margin: 14px 0 0;
- padding: 0;
- max-height: 20rem;
- list-style: none;
- overflow: auto;
-}
-
-.gen2-repo-list button {
- display: flex;
- align-items: center;
- gap: 9px;
- width: 100%;
- padding: 9px 12px;
- border: 0;
- border-radius: 9px;
- background: none;
- color: var(--ink);
- cursor: pointer;
- font: inherit;
- font-size: 0.8125rem;
- text-align: left;
-}
-
-.gen2-repo-list button:hover:not(:disabled) {
- background: var(--surface-2);
-}
-
-.gen2-repo-list button:focus-visible {
- outline: 2px solid var(--gold);
- outline-offset: -2px;
-}
-
-.gen2-repo-name {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.gen2-repo-tag,
-.gen2-repo-branch,
-.gen2-card-repo {
- color: var(--muted);
- font-size: 0.6875rem;
-}
-
-.gen2-repo-tag {
- padding: 1px 7px;
- border: 1px solid var(--line);
- border-radius: 999px;
-}
-
-.gen2-repo-mark {
- flex: none;
- width: 14px;
- height: 14px;
-}
diff --git a/apps/web/app/gen2/page.tsx b/apps/web/app/gen2/page.tsx
index 00d526cba..4658ab4e4 100644
--- a/apps/web/app/gen2/page.tsx
+++ b/apps/web/app/gen2/page.tsx
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
+import "@/app/product-theme.css";
+
import { CreateGen2WorkspaceForm } from "@/components/gen2/create-workspace-form";
import { Gen2WorkspaceList } from "@/components/gen2/workspace-list";
import { AppChrome } from "@/components/shell/app-chrome";
@@ -19,23 +21,32 @@ export default async function Gen2WorkspacesPage() {
return (
-
- Gen 2
- Cloud workspaces
-
- A workspace is a Firecracker instance you can share. Open one and its
- machine comes up; send the link and anyone you invite works on that
- same machine, alongside an AI agent.
-
- workspace.role === "owner").length
- }
- appSlug={process.env.GITHUB_APP_SLUG}
- connectGitHub={connectGitHubAccount.bind(null, "/gen2")}
- />
-
+
+
+
+
workspace.role === "owner")
+ .length
+ }
+ appSlug={process.env.GITHUB_APP_SLUG}
+ connectGitHub={connectGitHubAccount.bind(null, "/gen2")}
+ />
+
+
);
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/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/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/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() ? (
+
);
}
diff --git a/apps/web/components/gen2/workspace-list.test.tsx b/apps/web/components/gen2/workspace-list.test.tsx
index 8b8bbbaf2..8e82a8c87 100644
--- a/apps/web/components/gen2/workspace-list.test.tsx
+++ b/apps/web/components/gen2/workspace-list.test.tsx
@@ -23,34 +23,64 @@ const ownerWorkspace: Gen2Workspace = {
updatedAt: "2026-09-20T20:00:00.000Z",
};
+function workspaceNamed(name: string, overrides: Partial
= {}) {
+ return {
+ ...ownerWorkspace,
+ id: `id-${name}`,
+ name,
+ ...overrides,
+ } satisfies Gen2Workspace;
+}
+
describe("Gen2WorkspaceList", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.stubGlobal(
- "confirm",
- vi.fn(() => true),
- );
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 204 })),
);
});
- it("confirms permanent deletion and refreshes the owner count", async () => {
+ it("deletes only after an in-app confirmation that names what is lost", async () => {
render( );
- fireEvent.click(screen.getByRole("button", { name: "Delete Studio" }));
+ fireEvent.click(screen.getByRole("button", { name: "Actions for Studio" }));
+ fireEvent.click(screen.getByRole("menuitem", { name: "Delete workspace" }));
+ const dialog = screen.getByRole("alertdialog");
+ expect(dialog).toHaveTextContent("all saved files");
+ expect(dialog).toHaveTextContent("every chat and its history");
+ expect(fetch).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole("button", { name: "Delete workspace" }));
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
`/api/gen2/workspaces/${ownerWorkspace.id}`,
{ method: "DELETE" },
),
);
- expect(window.confirm).toHaveBeenCalledWith(
- expect.stringContaining("permanently deletes the workspace"),
- );
expect(mocks.refresh).toHaveBeenCalledOnce();
- expect(screen.getByText("No workspaces yet.")).toBeInTheDocument();
+ expect(screen.getByText("No workspaces yet")).toBeInTheDocument();
+ });
+
+ it("keeps the workspace when the confirmation is cancelled", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Actions for Studio" }));
+ fireEvent.click(screen.getByRole("menuitem", { name: "Delete workspace" }));
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(fetch).not.toHaveBeenCalled();
+ expect(screen.queryByRole("alertdialog")).toBeNull();
+ expect(screen.getByText("Studio")).toBeInTheDocument();
+ });
+
+ it("offers no actions on a workspace shared with you", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Shared with you")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Actions for/ })).toBeNull();
});
it("shows interrupted deletion as retryable and not openable", () => {
@@ -63,11 +93,68 @@ describe("Gen2WorkspaceList", () => {
expect(screen.getByText("Deleting")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /Studio/ })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Actions for Studio" }));
expect(
- screen.getByRole("button", { name: "Retry deletion of Studio" }),
+ screen.getByRole("menuitem", { name: "Retry deletion" }),
).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(
"Deletion did not finish. Retry deletion to continue.",
);
});
+
+ it("tells not-started and stopped workspaces apart", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Not started")).toBeInTheDocument();
+ expect(screen.getByText("Stopped")).toBeInTheDocument();
+ });
+
+ it("explains the empty state and points at the create form", () => {
+ render( );
+ expect(screen.getByText("No workspaces yet")).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "Create your first workspace" }),
+ ).toHaveAttribute("href", "#new-workspace");
+ });
+
+ it("adds search and sort once there are several workspaces", () => {
+ const few = [workspaceNamed("A"), workspaceNamed("B")];
+ const { unmount } = render( );
+ expect(screen.queryByLabelText("Search workspaces")).toBeNull();
+ unmount();
+
+ render(
+ ,
+ );
+ const names = () =>
+ screen.getAllByRole("link").map((link) => link.textContent);
+ expect(names()[0]).toContain("Zulu");
+
+ fireEvent.change(screen.getByLabelText("Sort workspaces"), {
+ target: { value: "name" },
+ });
+ expect(names()[0]).toContain("Alpha");
+
+ fireEvent.change(screen.getByLabelText("Search workspaces"), {
+ target: { value: "mik" },
+ });
+ expect(screen.getAllByRole("link")).toHaveLength(1);
+ fireEvent.change(screen.getByLabelText("Search workspaces"), {
+ target: { value: "nothing" },
+ });
+ expect(screen.getByText(/No workspaces match/)).toBeInTheDocument();
+ });
});
diff --git a/apps/web/components/gen2/workspace-list.tsx b/apps/web/components/gen2/workspace-list.tsx
index 334d6c93b..56de6b835 100644
--- a/apps/web/components/gen2/workspace-list.tsx
+++ b/apps/web/components/gen2/workspace-list.tsx
@@ -1,20 +1,162 @@
"use client";
-import { useState } from "react";
+import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { LoaderCircle, Trash2 } from "lucide-react";
+import {
+ Cloud,
+ LoaderCircle,
+ MoreHorizontal,
+ Search,
+ Trash2,
+} from "lucide-react";
import type { Gen2Workspace } from "@codev/contracts";
+import { ConfirmDialog } from "@/components/settings/confirm-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button, LinkButton } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/platform/utils";
+
+// "Not started" and "Stopped" are different states: the first has never had a
+// machine, the second had one and it was shut down.
const STATUS_LABEL: Record = {
- pending: "Idle",
+ pending: "Not started",
provisioning: "Starting",
ready: "Ready",
failed: "Failed",
- stopped: "Idle",
+ stopped: "Stopped",
deleting: "Deleting",
};
+const STATUS_DOT: Record = {
+ pending: "bg-muted-foreground/50",
+ provisioning: "bg-amber-400 animate-pulse motion-reduce:animate-none",
+ ready: "bg-emerald-500",
+ failed: "bg-destructive",
+ stopped: "bg-muted-foreground/50",
+ deleting: "bg-destructive",
+};
+
+type SortKey = "updated" | "name";
+
+/** Search and sort earn their space only once there is a list to search. */
+const CONTROLS_THRESHOLD = 4;
+
+const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
+ ["year", 365 * 24 * 60 * 60],
+ ["month", 30 * 24 * 60 * 60],
+ ["day", 24 * 60 * 60],
+ ["hour", 60 * 60],
+ ["minute", 60],
+];
+
+function formatUpdated(iso: string): string {
+ const seconds = (new Date(iso).getTime() - Date.now()) / 1000;
+ if (Number.isNaN(seconds)) return "";
+ const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
+ for (const [unit, size] of RELATIVE_UNITS) {
+ if (Math.abs(seconds) >= size) {
+ return formatter.format(Math.round(seconds / size), unit);
+ }
+ }
+ return "just now";
+}
+
+/**
+ * The per-workspace actions menu. Delete lives here, one step away from the
+ * card, so it is not a misclick beside the link that opens the workspace.
+ */
+function WorkspaceMenu({
+ workspace,
+ busy,
+ disabled,
+ onDelete,
+}: {
+ workspace: Gen2Workspace;
+ busy: boolean;
+ disabled: boolean;
+ onDelete: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+ const triggerRef = useRef(null);
+ const itemRef = useRef(null);
+ const menuId = useId();
+ const retry = workspace.status === "deleting";
+
+ useEffect(() => {
+ if (!open) return;
+ itemRef.current?.focus();
+ function onPointerDown(event: PointerEvent) {
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ setOpen(false);
+ triggerRef.current?.focus();
+ }
+ }
+ document.addEventListener("pointerdown", onPointerDown);
+ document.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.removeEventListener("pointerdown", onPointerDown);
+ document.removeEventListener("keydown", onKeyDown);
+ };
+ }, [open]);
+
+ return (
+
+ setOpen((current) => !current)}
+ ref={triggerRef}
+ size="icon-sm"
+ type="button"
+ variant="secondary"
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ {open ? (
+
+ ) : null}
+
+ );
+}
+
export function Gen2WorkspaceList({
workspaces: initialWorkspaces,
}: {
@@ -22,18 +164,33 @@ export function Gen2WorkspaceList({
}) {
const router = useRouter();
const [workspaces, setWorkspaces] = useState(initialWorkspaces);
+ const [confirming, setConfirming] = useState(null);
const [deletingId, setDeletingId] = useState(null);
+ const [query, setQuery] = useState("");
+ const [sort, setSort] = useState("updated");
const [actionError, setActionError] = useState<{
workspaceId: string;
message: string;
} | null>(null);
- async function deleteWorkspace(workspace: Gen2Workspace) {
- const confirmed = window.confirm(
- `Delete “${workspace.name}”? This permanently deletes the workspace, its saved files, and all chat history for everyone with access.`,
+ const visible = useMemo(() => {
+ const needle = query.trim().toLowerCase();
+ const matches = needle
+ ? workspaces.filter(
+ (workspace) =>
+ workspace.name.toLowerCase().includes(needle) ||
+ (workspace.repository?.fullName.toLowerCase().includes(needle) ??
+ false),
+ )
+ : workspaces;
+ return [...matches].sort((a, b) =>
+ sort === "name"
+ ? a.name.localeCompare(b.name)
+ : b.updatedAt.localeCompare(a.updatedAt),
);
- if (!confirmed) return;
+ }, [workspaces, query, sort]);
+ async function deleteWorkspace(workspace: Gen2Workspace) {
setDeletingId(workspace.id);
setActionError(null);
try {
@@ -67,90 +224,178 @@ export function Gen2WorkspaceList({
}
if (workspaces.length === 0) {
- return No workspaces yet.
;
+ return (
+
+
+
+
+
+
No workspaces yet
+
+ A workspace is a cloud computer with your code and an AI agent on
+ it. Create one above, then send its link to invite teammates.
+
+
+
+ Create your first workspace
+
+
+ );
}
return (
-
- {workspaces.map((workspace) => {
- const isDeleting = deletingId === workspace.id;
- const deletionPending = workspace.status === "deleting";
- const error =
- actionError?.workspaceId === workspace.id
- ? actionError.message
- : null;
- const cardContent = (
- <>
- {workspace.name}
- {workspace.repository ? (
-
- {workspace.repository.fullName}
+
+ {workspaces.length >= CONTROLS_THRESHOLD ? (
+
+
+
+ setQuery(event.target.value)}
+ placeholder="Search workspaces"
+ type="search"
+ value={query}
+ />
+
+
setSort(event.target.value as SortKey)}
+ value={sort}
+ >
+ Recently updated
+ Name
+
+
+ ) : null}
+
+ {visible.length === 0 ? (
+
+ No workspaces match “{query.trim()}”.
+
+ ) : (
+
+ {visible.map((workspace) => {
+ const isDeleting = deletingId === workspace.id;
+ const deletionPending = workspace.status === "deleting";
+ const error =
+ actionError?.workspaceId === workspace.id
+ ? actionError.message
+ : null;
+ const isOwner = workspace.role === "owner";
+ const cardContent = (
+
+
+ {workspace.name}
+
+
+
+ {isOwner ? "Owned by you" : "Shared with you"}
+
+ {workspace.repository ? (
+
+ {workspace.repository.fullName}
+
+ ) : null}
+
+ Updated {formatUpdated(workspace.updatedAt)}
+
+
+
+ );
+ const status = (
+
+
+ {STATUS_LABEL[workspace.status]}
- ) : null}
-
-
- {STATUS_LABEL[workspace.status]}
-
- >
- );
-
- return (
-
-
- {deletionPending ? (
-
{cardContent}
- ) : (
-
- {cardContent}
-
- )}
- {workspace.role === "owner" ? (
-
void deleteWorkspace(workspace)}
- >
- {isDeleting ? (
-
+ );
+
+ return (
+
+
+ {deletionPending ? (
+
+ {cardContent}
+ {status}
+
) : (
-
+
+
+ {cardContent}
+ {status}
+
+
)}
-
- ) : null}
-
- {deletionPending && !error ? (
-
- {workspace.lastError
- ? "Deletion did not finish. Retry deletion to continue."
- : "Workspace deletion is in progress."}
-
- ) : null}
- {error ? (
-
- {error}
-
- ) : null}
-
- );
- })}
-
+ {isOwner ? (
+ setConfirming(workspace)}
+ workspace={workspace}
+ />
+ ) : null}
+
+ {deletionPending && !error ? (
+
+ {workspace.lastError
+ ? "Deletion did not finish. Retry deletion to continue."
+ : "Workspace deletion is in progress."}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+ {confirming ? (
+ setConfirming(null)}
+ onConfirm={() => {
+ const target = confirming;
+ setConfirming(null);
+ void deleteWorkspace(target);
+ }}
+ title={`Delete “${confirming.name}”?`}
+ >
+ This permanently deletes, for everyone with access:
+
+ the workspace and its machine
+ all saved files
+ every chat and its history
+
+ This can't be undone.
+
+ ) : null}
+
);
}
diff --git a/apps/web/components/settings/SettingsSidebar.tsx b/apps/web/components/settings/SettingsSidebar.tsx
index 0332a75c0..b3e64f6ec 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,52 +11,53 @@ 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[] = [
+ {
+ label: "Account",
+ items: [
+ { name: "Profile", href: "/settings/personal/profile", icon: User },
+ ],
+ },
{
- name: "AI Provider Accounts",
- href: "/settings/personal/providers",
- icon: Plug,
- badge: "Optional",
- keywords: [
- "openai",
- "anthropic",
- "api key",
- "codex",
- "claude",
- "cursor",
- "connect",
- "sign in",
+ label: "Agents",
+ items: [
+ {
+ name: "AI Provider Accounts",
+ href: "/settings/personal/providers",
+ icon: Plug,
+ },
+ {
+ name: "Environment Variables",
+ href: "/settings/personal/environment",
+ icon: KeyRound,
+ },
],
},
{
- name: "Integrations",
- href: "/settings/personal/integrations",
- icon: Blocks,
- keywords: ["github", "gitlab", "linear", "jira"],
+ 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 (
-
-
+
+ {/* Leaves room for the app sidebar's floating show button. */}
+
-
-
-
- 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..d1025f4d2 100644
--- a/apps/web/components/settings/environment-variables-panel.tsx
+++ b/apps/web/components/settings/environment-variables-panel.tsx
@@ -2,11 +2,26 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
-import { Pencil, Plus, Trash2 } from "lucide-react";
+import { ClipboardPaste, 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";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ environmentNameError,
+ parseDotenv,
+} from "@/components/settings/parse-dotenv";
function maskedValue(lastFour: string | null) {
if (!lastFour) return "••••••••";
@@ -29,7 +44,20 @@ 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);
+ const [showImport, setShowImport] = useState(false);
+ const [importText, setImportText] = useState("");
+ const nameError = environmentNameError(name);
+ const parsedImport = parseDotenv(importText);
+
+ // 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);
@@ -49,7 +77,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 +92,54 @@ 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);
+ }
+
+ /**
+ * Pasted .env text is saved one variable at a time through the same route as
+ * a single add, so every server-side rule (limit, duplicate names, size)
+ * applies unchanged; the result is one summary rather than a toast per line.
+ */
+ async function importVariables() {
+ if (parsedImport.entries.length === 0) return;
+ setBusy(true);
+ setMessage(null);
+ const saved: EnvironmentVariable[] = [];
+ const failed: string[] = [];
+ for (const entry of parsedImport.entries) {
+ const response = await fetch("/api/settings/environment", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(entry),
+ });
+ const payload = (await response.json().catch(() => ({}))) as {
+ variable?: EnvironmentVariable;
+ };
+ if (response.ok && payload.variable) saved.push(payload.variable);
+ else failed.push(entry.name);
+ }
+ if (saved.length > 0) {
+ refreshList(
+ [...variables, ...saved].sort((a, b) => a.name.localeCompare(b.name)),
+ );
+ }
+ if (failed.length === 0) {
+ setImportText("");
+ setShowImport(false);
+ }
+ const parts = [
+ saved.length > 0
+ ? `Imported ${saved.length} variable${saved.length === 1 ? "" : "s"}.`
+ : null,
+ failed.length > 0
+ ? `Not imported (already exist or over the limit): ${failed.join(", ")}.`
+ : null,
+ ].filter(Boolean);
+ report({
+ tone: failed.length > 0 ? "warning" : "success",
+ text: parts.join(" "),
+ });
setBusy(false);
}
@@ -81,7 +156,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 +170,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 +184,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 +192,332 @@ 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"}
-
-
+
+
+
+ Personal .env
+
+ Encrypted at rest and write-only: values are never shown again after
+ you save them. They are passed to every Gen 2 agent session you
+ start, like a personal{" "}
+ .env. Your provider login
+ always wins over a variable with the same name.
+
+
+ {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}
+
- {showAdd ? (
-
-
-
- Key
-
- setName(event.target.value.toUpperCase())}
- placeholder="DATABASE_URL"
- spellCheck={false}
- value={name}
- />
-
-
-
- Value
+
+ {showAdd ? (
+ {
+ event.preventDefault();
+ if (!busy && name.trim() && value && !nameError)
+ void addVariable();
+ }}
+ >
+
+
+
Name
+
+ setName(event.target.value.toUpperCase())
+ }
+ placeholder="DATABASE_URL"
+ spellCheck={false}
+ value={name}
+ />
+ {nameError ? (
+
+ {nameError}
+
+ ) : null}
+
+
+ Value
+ setValue(event.target.value)}
+ placeholder="Sensitive value"
+ spellCheck={false}
+ type="password"
+ value={value}
+ />
+
+
+
+ setShowAdd(false)}
+ size="sm"
+ type="button"
+ variant="secondary"
+ >
+ Cancel
+
+
+ {busy ? "Saving…" : "Save variable"}
+
+
+
+ ) : null}
+
+ {showImport ? (
+ {
+ event.preventDefault();
+ void importVariables();
+ }}
+ >
+
+
Paste your .env
+
setImportText(event.target.value)}
+ placeholder={"DATABASE_URL=postgres://...\nAPI_KEY=..."}
+ spellCheck={false}
+ value={importText}
+ />
+
+ {importText.trim()
+ ? `${parsedImport.entries.length} variable${parsedImport.entries.length === 1 ? "" : "s"} ready${parsedImport.skipped.length > 0 ? `; line${parsedImport.skipped.length === 1 ? "" : "s"} ${parsedImport.skipped.join(", ")} can't be used and will be skipped` : ""}. Existing names are not overwritten.`
+ : "One NAME=value per line. Comments and export prefixes are fine."}
+
+
+
+ setShowImport(false)}
+ size="sm"
+ type="button"
+ variant="secondary"
+ >
+ Cancel
+
+
+ {busy ? "Importing…" : "Import variables"}
+
+
+
+ ) : null}
+
+ {variables.length === 0 && !showAdd && !showImport ? (
+
+
+
-
setValue(event.target.value)}
- placeholder="Sensitive value"
- spellCheck={false}
- type="password"
- value={value}
- />
-
-
void addVariable()}
- size="sm"
- type="button"
+
+
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
+
+ {
+ setShowImport(true);
+ setMessage(null);
+ }}
+ type="button"
+ >
+ or paste a .env file
+
+
+ ) : null}
+
+ {variables.length > 0 ? (
+
- {busy ? "Saving…" : "Save"}
-
-
- ) : null}
+ {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 ? (
+ <>
+
void saveEdit(variable.id)}
+ size="sm"
+ type="button"
+ >
+ Save
+
+
{
+ setEditingId(null);
+ setEditValue("");
+ }}
+ size="sm"
+ type="button"
+ variant="secondary"
+ >
+ Cancel
+
+ >
+ ) : (
+ <>
+
{
+ setEditingId(variable.id);
+ setEditValue("");
+ setMessage(null);
+ }}
+ size="icon-sm"
+ title="Replace value"
+ type="button"
+ variant="secondary"
+ >
+
+
+
setDeleting(variable)}
+ size="icon-sm"
+ title="Delete"
+ type="button"
+ variant="secondary"
+ >
+
+
+ >
+ )}
+
+
+ );
+ })}
+
+ ) : null}
- {variables.length === 0 && !showAdd ? (
-
-
No environment variables yet
-
- Add keys your agents and sandboxes should be able to use later.
+ {message ? (
+
+ {message.text}
-
- ) : (
-
- )}
+ ) : null}
+
- {message ? (
- 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/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/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/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"}
);
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/shell/app-chrome.tsx b/apps/web/components/shell/app-chrome.tsx
index 48ba01159..b3c15327c 100644
--- a/apps/web/components/shell/app-chrome.tsx
+++ b/apps/web/components/shell/app-chrome.tsx
@@ -4,6 +4,7 @@ import { headers } from "next/headers";
import { isGitHubAuthConfigured } from "@codev/config";
+import { AppSidebarFrame } from "@/components/shell/app-sidebar-frame";
import { AppSidebarNav } from "@/components/shell/app-sidebar-nav";
import { FeedbackWidget } from "@/components/shell/feedback-widget";
import { ProfileMenu } from "@/components/shell/profile-menu";
@@ -53,26 +54,27 @@ export async function AppChrome({
if (sidebar) {
return (
-
+
);
}
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}
+
+ );
+}
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 (
+
+ );
+}