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")} - /> - +
+
+
+ + Gen 2 + +

+ Cloud workspaces +

+

+ A workspace is a cloud computer you and your teammates share, with + an AI agent built in. Open one and it starts on its own; send the + link and anyone you invite works on that same computer. +

+
+ 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"} +

+
+
+ + + {params.name === "saved" ? ( +

+ Display name updated. +

+ ) : null} + {nameError ? ( +

+ {nameError} +

+ ) : null} +
@@ -135,8 +192,15 @@ export default async function PersonalProfilePage({ /> + Repository access + + ) : isGitHubAuthConfigured() ? (
- {connectedAccounts.hasPassword ? null : ( + {connectedAccounts.hasPassword ? ( + + + {passwordJustChanged ? ( +

+ Password changed. +

+ ) : null} + {passwordError ? ( +

+ {passwordError} +

+ ) : null} + +
+ ) : ( )} + + + + + + Download account data + + ); } diff --git a/apps/web/auth.ts b/apps/web/auth.ts index ff2516569..93462878f 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -102,7 +102,7 @@ async function clearGithubLinkCookie() { } } -export const { handlers, auth, signIn, signOut } = NextAuth({ +export const { handlers, auth, signIn, signOut, unstable_update } = NextAuth({ trustHost: true, session: { strategy: "jwt" }, cookies: { @@ -404,7 +404,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ return true; }, - async jwt({ token, account, profile, user }) { + async jwt({ token, account, profile, user, trigger, session }) { + // Settings refreshes the shown display name after an edit. Only the name + // is taken from the update payload; identity fields never are. + if (trigger === "update") { + const name = (session as { user?: { name?: unknown } } | undefined) + ?.user?.name; + if (typeof name === "string" && name.trim()) token.name = name.trim(); + return token; + } if (account?.provider === "credentials" && user?.id) { token.localUserId = user.id; } else if ( diff --git a/apps/web/components/gen2/create-workspace-form.test.tsx b/apps/web/components/gen2/create-workspace-form.test.tsx index 7169c0dbc..af6bc456a 100644 --- a/apps/web/components/gen2/create-workspace-form.test.tsx +++ b/apps/web/components/gen2/create-workspace-form.test.tsx @@ -34,15 +34,31 @@ function stubFetch() { ); } +async function openRepositories() { + fireEvent.click(screen.getByRole("radio", { name: /GitHub repository/ })); + return screen.findByText("ada/looms"); +} + describe("CreateGen2WorkspaceForm", () => { beforeEach(() => { vi.clearAllMocks(); stubFetch(); }); + it("creates nothing until Create is pressed", async () => { + render(); + await openRepositories(); + fireEvent.click(screen.getByRole("button", { name: /ada\/looms/ })); + expect(fetch).not.toHaveBeenCalledWith( + "/api/gen2/workspaces", + expect.anything(), + ); + expect(mocks.push).not.toHaveBeenCalled(); + }); + it("creates a blank workspace and opens it", async () => { render(); - fireEvent.click(screen.getByRole("button", { name: /Blank workspace/ })); + fireEvent.click(screen.getByRole("button", { name: "Create workspace" })); await waitFor(() => expect(mocks.push).toHaveBeenCalledWith("/gen2/ws-1")); expect(fetch).toHaveBeenCalledWith( "/api/gen2/workspaces", @@ -50,6 +66,30 @@ describe("CreateGen2WorkspaceForm", () => { ); }); + it("sends the optional name", async () => { + render(); + fireEvent.change(screen.getByLabelText("Name (optional)"), { + target: { value: " Studio " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create workspace" })); + await waitFor(() => expect(mocks.push).toHaveBeenCalled()); + expect(fetch).toHaveBeenCalledWith( + "/api/gen2/workspaces", + expect.objectContaining({ body: JSON.stringify({ name: "Studio" }) }), + ); + }); + + it("always shows how many workspaces are used", () => { + render( + , + ); + expect(screen.getByText("1 of 2 used")).toBeInTheDocument(); + expect(screen.queryByRole("status")).toBeNull(); + }); + it("blocks workspace creation when the owner limit is reached", () => { render( { />, ); expect( - screen.getByRole("button", { name: /Blank workspace/ }), + screen.getByRole("button", { name: "Create workspace" }), ).toBeDisabled(); expect(screen.getByRole("status")).toHaveTextContent( "You own 2 of 2 Gen 2 workspaces. Delete one to create another.", @@ -67,22 +107,25 @@ describe("CreateGen2WorkspaceForm", () => { it("offers GitHub when it is not connected yet", () => { render(); + fireEvent.click(screen.getByRole("radio", { name: /GitHub repository/ })); expect( screen.getByRole("button", { name: /Connect GitHub/ }), ).toBeInTheDocument(); expect(screen.queryByLabelText("Search repositories")).toBeNull(); }); - it("lists repositories once GitHub is connected", async () => { + it("lists repositories with a heading once GitHub is chosen", async () => { render(); - expect(await screen.findByText("ada/looms")).toBeInTheDocument(); + expect(await openRepositories()).toBeInTheDocument(); + expect(screen.getByText("Your repositories")).toBeInTheDocument(); expect(screen.getByText("Private")).toBeInTheDocument(); expect(screen.getByText("trunk")).toBeInTheDocument(); + expect(screen.getByText("2 repositories")).toBeInTheDocument(); }); it("filters the list as you type", async () => { render(); - await screen.findByText("ada/looms"); + await openRepositories(); fireEvent.change(screen.getByLabelText("Search repositories"), { target: { value: "card" }, }); @@ -90,9 +133,41 @@ describe("CreateGen2WorkspaceForm", () => { expect(screen.getByText("ada/cards")).toBeInTheDocument(); }); + it("says when the list is cut off", async () => { + const many = Array.from({ length: 45 }, (_, index) => ({ + id: index + 1, + full_name: `ada/repo-${index}`, + private: false, + default_branch: "main", + })); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + const path = String(url); + if (path.endsWith("/repositories")) { + return new Response(JSON.stringify({ repositories: many })); + } + return new Response( + JSON.stringify({ + installations: [ + { id: 1, account: { login: "ada", avatar_url: "" } }, + ], + }), + ); + }), + ); + render(); + fireEvent.click(screen.getByRole("radio", { name: /GitHub repository/ })); + expect(await screen.findByText(/Showing 40 of 45/)).toBeInTheDocument(); + }); + it("creates from the repository that was picked", async () => { render(); - fireEvent.click(await screen.findByRole("button", { name: /ada\/looms/ })); + await openRepositories(); + const create = screen.getByRole("button", { name: "Create workspace" }); + expect(create).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: /ada\/looms/ })); + fireEvent.click(create); await waitFor(() => expect(mocks.push).toHaveBeenCalledWith("/gen2/ws-1")); // The browser sends ids only; the control plane resolves the commit and // fetches the source, so no repository contents pass through here. @@ -104,6 +179,37 @@ describe("CreateGen2WorkspaceForm", () => { ); }); + it("shows a retryable error when GitHub cannot be reached", async () => { + let failing = true; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + const path = String(url); + if (path.endsWith("/installations")) { + return failing + ? new Response("{}", { status: 502 }) + : new Response( + JSON.stringify({ + installations: [ + { id: 1, account: { login: "ada", avatar_url: "" } }, + ], + }), + ); + } + return new Response(JSON.stringify({ repositories: REPOS })); + }), + ); + render(); + fireEvent.click(screen.getByRole("radio", { name: /GitHub repository/ })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Couldn't load your repositories", + ); + + failing = false; + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByText("ada/looms")).toBeInTheDocument(); + }); + it("reports a create failure instead of navigating", async () => { vi.stubGlobal( "fetch", @@ -115,7 +221,7 @@ describe("CreateGen2WorkspaceForm", () => { ), ); render(); - fireEvent.click(screen.getByRole("button", { name: /Blank workspace/ })); + fireEvent.click(screen.getByRole("button", { name: "Create workspace" })); expect(await screen.findByRole("alert")).toHaveTextContent( "Out of capacity.", ); diff --git a/apps/web/components/gen2/create-workspace-form.tsx b/apps/web/components/gen2/create-workspace-form.tsx index febeafdb0..e0bed0943 100644 --- a/apps/web/components/gen2/create-workspace-form.tsx +++ b/apps/web/components/gen2/create-workspace-form.tsx @@ -1,11 +1,24 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { startTransition, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { Plus, Search } from "lucide-react"; +import { Check, LoaderCircle, Plus, Search } from "lucide-react"; import { GithubMark } from "@/components/settings/github-mark"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { GEN2_MAX_OWNED_WORKSPACES } from "@/lib/gen2/constants"; +import { cn } from "@/lib/platform/utils"; type Installation = { id: number; @@ -18,9 +31,17 @@ type Repository = { default_branch: string; }; +type Loaded = { key: string; status: "ok" | "error"; items: T[] }; + +const REPOSITORY_PAGE_SIZE = 40; + /** * Start a workspace: blank, or from a GitHub repository. * + * Nothing is created until "Create workspace" is pressed: choosing a source and + * a repository only fills in the form, so a stray click can't spend one of the + * member's few workspaces. + * * The repository never arrives through the browser. Picking one records the * installation and repository ids; the control plane resolves the commit and * either hands the guest a plain public clone URL or ships a bounded snapshot @@ -44,53 +65,110 @@ export function CreateGen2WorkspaceForm({ }) { const router = useRouter(); const atWorkspaceLimit = ownedWorkspaceCount >= GEN2_MAX_OWNED_WORKSPACES; - const [installations, setInstallations] = useState([]); + const [source, setSource] = useState<"blank" | "github">("blank"); + const [name, setName] = useState(""); + const [reloadKey, setReloadKey] = useState(0); + const [installs, setInstalls] = useState | null>(null); const [installationId, setInstallationId] = useState(null); - const [repositories, setRepositories] = useState([]); + const [repos, setRepos] = useState | null>(null); + const [selectedRepoId, setSelectedRepoId] = useState(null); const [query, setQuery] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + // Loading is derived, not stored: a result belongs to the request key that + // produced it, so "no result for the current key yet" means "loading". + const installKey = String(reloadKey); + const repoKey = `${installationId}:${reloadKey}`; + const installsLoading = githubConnected && installs?.key !== installKey; + const reposLoading = + source === "github" && installationId !== null && repos?.key !== repoKey; + useEffect(() => { if (!githubConnected) return; + let cancelled = false; void (async () => { - const response = await fetch("/api/github/installations"); - if (!response.ok) return; - const payload = (await response.json()) as { - installations?: Installation[]; - }; - setInstallations(payload.installations ?? []); - setInstallationId( - (current) => current ?? payload.installations?.[0]?.id ?? null, - ); + let loaded: Loaded; + try { + const response = await fetch("/api/github/installations"); + const payload = response.ok + ? ((await response.json()) as { installations?: Installation[] }) + : null; + loaded = { + key: installKey, + status: payload ? "ok" : "error", + items: payload?.installations ?? [], + }; + } catch { + loaded = { key: installKey, status: "error", items: [] }; + } + if (cancelled) return; + setInstalls(loaded); + setInstallationId((current) => current ?? loaded.items[0]?.id ?? null); })(); - }, [githubConnected]); + return () => { + cancelled = true; + }; + }, [githubConnected, installKey]); useEffect(() => { - if (installationId === null) return; + if (source !== "github" || installationId === null) return; + let cancelled = false; void (async () => { - const response = await fetch( - `/api/github/installations/${installationId}/repositories`, - ); - if (!response.ok) return; - const payload = (await response.json()) as { - repositories?: Repository[]; - }; - setRepositories(payload.repositories ?? []); + let loaded: Loaded; + try { + const response = await fetch( + `/api/github/installations/${installationId}/repositories`, + ); + const payload = response.ok + ? ((await response.json()) as { repositories?: Repository[] }) + : null; + loaded = { + key: repoKey, + status: payload ? "ok" : "error", + items: payload?.repositories ?? [], + }; + } catch { + loaded = { key: repoKey, status: "error", items: [] }; + } + if (!cancelled) setRepos(loaded); })(); - }, [installationId]); + return () => { + cancelled = true; + }; + }, [source, installationId, repoKey]); - const visible = useMemo(() => { + const repositories = useMemo( + () => (repos?.key === repoKey ? repos.items : []), + [repos, repoKey], + ); + const matches = useMemo(() => { const needle = query.trim().toLowerCase(); - const matches = needle + return needle ? repositories.filter((repo) => repo.full_name.toLowerCase().includes(needle), ) : repositories; - return matches.slice(0, 40); }, [repositories, query]); + const visible = matches.slice(0, REPOSITORY_PAGE_SIZE); + const selectedRepo = + repositories.find((repo) => repo.id === selectedRepoId) ?? null; + const loadFailed = + installs?.key === installKey && installs.status === "error" + ? true + : repos?.key === repoKey && repos.status === "error"; + const canCreate = + !busy && !atWorkspaceLimit && (source === "blank" || selectedRepo !== null); + + async function create() { + const trimmedName = name.trim(); + const body: Record = {}; + if (trimmedName) body.name = trimmedName; + if (source === "github" && selectedRepo && installationId !== null) { + body.installationId = installationId; + body.repositoryId = selectedRepo.id; + } - async function create(body: Record) { setBusy(true); setError(""); try { @@ -116,109 +194,288 @@ export function CreateGen2WorkspaceForm({ } } + const summary = + source === "github" + ? selectedRepo + ? `Creates a workspace from ${selectedRepo.full_name}.` + : "Choose a repository to continue." + : "Creates an empty workspace."; + return ( -
-
- - - {githubConnected ? ( - <> - {installations.length > 1 ? ( - - ) : null} - - - ) : ( - - - - )} -
- - {atWorkspaceLimit ? ( -

- You own {ownedWorkspaceCount} of {GEN2_MAX_OWNED_WORKSPACES} Gen 2 - workspaces. Delete one to create another. -

- ) : null} - - {githubConnected && visible.length > 0 ? ( -
    - {visible.map((repo) => ( -
  • - -
  • - ))} -
- ) : null} - - {githubConnected && appSlug && repositories.length === 0 ? ( -

- No repositories yet.{" "} - +

{ + event.preventDefault(); + if (canCreate) void create(); + }} + > + +
+ New workspace + + Pick a starting point. Nothing is created until you press Create. + +
+ - Give CoDev access to some -
- . -

- ) : null} - - {error ? ( -

- {error} -

- ) : null} -
+ {ownedWorkspaceCount} of {GEN2_MAX_OWNED_WORKSPACES} used + + + + +
+ Start from +
+ {( + [ + { + id: "blank", + title: "Blank workspace", + hint: "An empty machine to build in", + icon:
+
+ + {source === "github" ? ( + githubConnected ? ( +
+
+ {(installs?.items.length ?? 0) > 1 ? ( + + ) : null} +
+
+
+ +

Your repositories

+ + {installsLoading || reposLoading ? ( +
+ {[0, 1, 2].map((row) => ( +
+ ))} +
+ ) : loadFailed ? ( +
+ + Couldn't load your repositories from GitHub. + + +
+ ) : visible.length > 0 ? ( + <> +
    + {visible.map((repo) => { + const selected = repo.id === selectedRepoId; + return ( +
  • + +
  • + ); + })} +
+

+ {matches.length > visible.length + ? `Showing ${visible.length} of ${matches.length}. Search to narrow the list.` + : `${matches.length} ${matches.length === 1 ? "repository" : "repositories"}`} +

+ + ) : query.trim() && repositories.length > 0 ? ( +

+ No repositories match “{query.trim()}”. +

+ ) : ( +

+ No repositories yet. + {appSlug ? ( + <> + {" "} + + Give CoDev access to some + + . + + ) : null} +

+ )} +
+ ) : ( +
+

+ Connect GitHub to start from one of your repositories. +

+ {/* Not a submit button: pressing Enter in the name field must + create the workspace, not start the GitHub connection. */} + +
+ ) + ) : null} + +
+ + setName(event.target.value)} + placeholder="Untitled workspace" + value={name} + /> +
+ + {atWorkspaceLimit ? ( +

+ You own {ownedWorkspaceCount} of {GEN2_MAX_OWNED_WORKSPACES} Gen 2 + workspaces. Delete one to create another. +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + + +

{summary}

+ +
+ + ); } 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 ( +
+ + {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 ? ( +
    +
    +
    + +
    + ) : 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} + + +
      + ); + const status = ( + + - ) : null} - - - - ); - - return ( -
    • -
      - {deletionPending ? ( -
      {cardContent}
      - ) : ( - - {cardContent} - - )} - {workspace.role === "owner" ? ( - - ) : 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 ( -