From c4de3ca02935a26689edaa4bc4849302a5acbfbe Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 5 Nov 2026 07:36:31 -0800 Subject: [PATCH 1/2] fix(hub): ship workspaces with no reviewers A workspace that never saved reviewers fell back to three demo personas, held as a default in both the hub and the interface. Both now default to an empty list, and the personas live in the seed data, which dev:seed writes to a workspace that has no saved reviewers. The share dialog's name placeholder no longer names one of them. --- .../hub/src/artifacts/approval-rounds.test.ts | 5 +- apps/hub/src/artifacts/approval-rounds.ts | 8 ++- apps/hub/src/config/approval-rounds.ts | 24 --------- apps/web/src/config.ts | 2 +- .../pages/workspace/approval-rounds.test.tsx | 53 +++++++++++++------ apps/web/src/workspace-approvals-setting.ts | 36 ++----------- docs/DEV.md | 3 +- scripts/seed-projects.ts | 32 +++++++++++ test/integration/seed-projects.test.ts | 2 + test/seed/index.ts | 25 +++++++++ 10 files changed, 109 insertions(+), 81 deletions(-) diff --git a/apps/hub/src/artifacts/approval-rounds.test.ts b/apps/hub/src/artifacts/approval-rounds.test.ts index 966190cb..08d607b7 100644 --- a/apps/hub/src/artifacts/approval-rounds.test.ts +++ b/apps/hub/src/artifacts/approval-rounds.test.ts @@ -12,7 +12,6 @@ import { type StoredApprovalRound, type StoredPackageVersion, } from "./approval-rounds.ts"; -import { DEFAULT_WORKSPACE_REVIEWERS } from "../config/approval-rounds.ts"; import type { WorkspaceReviewer } from "@corbits-builder/schemas/approval-round"; import { ProjectError } from "../projects/errors.ts"; import type { Actor } from "../lib/actor.ts"; @@ -111,9 +110,7 @@ function createFakeStore(options?: { sha256: string; }> = []; const rounds = new Map(); - const workspaceReviewers: WorkspaceReviewer[] = [ - ...DEFAULT_WORKSPACE_REVIEWERS, - ]; + const workspaceReviewers: WorkspaceReviewer[] = []; const store: ApprovalRoundStore = { now: () => currentNow, diff --git a/apps/hub/src/artifacts/approval-rounds.ts b/apps/hub/src/artifacts/approval-rounds.ts index 90e60abb..51424dff 100644 --- a/apps/hub/src/artifacts/approval-rounds.ts +++ b/apps/hub/src/artifacts/approval-rounds.ts @@ -42,7 +42,6 @@ import { settingsRecord, } from "../db/schema.ts"; import { - DEFAULT_WORKSPACE_REVIEWERS, incorporatedFeedbackMessage, packageReadme, type PackageManifestSource, @@ -472,10 +471,9 @@ export function createPgApprovalRoundStore( eq(settingsRecord.scopeKey, WORKSPACE_REVIEWERS_SCOPE_KEY), ), ); - const stored = - row === undefined ? null : parseWorkspaceReviewersField(row.fields); - if (stored !== null) return stored; - return DEFAULT_WORKSPACE_REVIEWERS.map((entry) => ({ ...entry })); + return row === undefined + ? [] + : (parseWorkspaceReviewersField(row.fields) ?? []); } return { diff --git a/apps/hub/src/config/approval-rounds.ts b/apps/hub/src/config/approval-rounds.ts index b1e56427..5a18b6e6 100644 --- a/apps/hub/src/config/approval-rounds.ts +++ b/apps/hub/src/config/approval-rounds.ts @@ -5,30 +5,6 @@ export type PackageManifestSource = { title?: string; }; -export const DEFAULT_WORKSPACE_REVIEWERS = [ - { - id: "ws-rev-dana-chen", - name: "Dana Chen", - persona: "CEO", - email: "dana.chen@example.com", - goal: "Approve Aurora Weather Studio scope, timeline, and Q3 budget to begin the Coder phase", - }, - { - id: "ws-rev-marcus-vance", - name: "Marcus Vance", - persona: "CTO", - email: "marcus.vance@example.com", - goal: "Sign off on radar tile payload budget, latency SLAs, and offline caching architecture", - }, - { - id: "ws-rev-priya-patel", - name: "Priya Patel", - persona: "VP Product", - email: "priya.patel@example.com", - goal: "Confirm user journey, severe alert triage flow, and accessibility targets", - }, -] as const; - export function packageReadme(input: { projectTitle: string; packageTitle: string; diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 9b244ff9..d9a2d8ae 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -1273,7 +1273,7 @@ export const wording = { }, reviewerRowLabel: (index: number) => `Reviewer ${String(index)}`, reviewerName: "Reviewer name", - reviewerNamePlaceholder: "Dana Chen", + reviewerNamePlaceholder: "Reviewer name", reviewerPersona: "Persona", reviewerPersonaPlaceholder: "Select role or persona…", reviewerPersonas: [ diff --git a/apps/web/src/pages/workspace/approval-rounds.test.tsx b/apps/web/src/pages/workspace/approval-rounds.test.tsx index 7367506d..a3736220 100644 --- a/apps/web/src/pages/workspace/approval-rounds.test.tsx +++ b/apps/web/src/pages/workspace/approval-rounds.test.tsx @@ -16,6 +16,10 @@ import { WhatsNewDialog } from "../../App.tsx"; import { wording } from "../../config.ts"; import { queryClient } from "../../queries/client.ts"; import { Settings, WorkspaceApprovals } from "../settings.tsx"; +import { + readWorkspaceReviewers, + writeWorkspaceReviewers, +} from "../../workspace-approvals-setting.ts"; const realArtifacts = { ...actualArtifacts }; const realConversation = { ...actualConversation }; @@ -1082,11 +1086,7 @@ describe("approval rounds panel", () => { expect(html).toContain( 'class="approval-inline-fields settings-reviewer-form"', ); - expect(html).toContain('aria-label="Reviewer 1 Pick from workspace"'); - expect(html).toContain("Choose saved reviewer…"); - expect(html).toContain("Dana Chen (CEO)"); - expect(html).toContain("Marcus Vance (CTO)"); - expect(html).toContain("Priya Patel (VP Product)"); + expect(html).not.toContain("Pick from workspace"); expect(html).toContain('aria-label="Reviewer 1 Reviewer name"'); expect(html).toContain('aria-label="Reviewer 1 Persona"'); expect(html).toContain("Budget approver"); @@ -1104,28 +1104,51 @@ describe("approval rounds panel", () => { expect(html).toContain("Cancel"); }); - test("renders WorkspaceApprovals settings section with require stakeholder approval toggle and seeded workspace reviewers", () => { + test("renders WorkspaceApprovals settings section with require stakeholder approval toggle and no workspace reviewers until some are saved", () => { const html = renderToStaticMarkup(); expect(html).toContain("Workspace approvals"); expect(html).toContain("Require stakeholder approval"); expect(html).toContain("Workspace reviewers"); - expect(html).toContain("Dana Chen"); - expect(html).toContain('CEO'); - expect(html).toContain("Marcus Vance"); - expect(html).toContain('CTO'); - expect(html).toContain("Priya Patel"); - expect(html).toContain( - 'VP Product', - ); + expect(html).toContain("No saved workspace reviewers yet."); expect(html).not.toContain("invite-form"); expect(html).toContain("settings-reviewer-form"); expect(html).toContain("approval-lean-input"); expect(html).toContain("approval-lean-select"); - expect(html).toContain("Remove"); expect(html).toContain("Add reviewer"); expect(html).toContain("Other…"); }); + test("lists saved workspace reviewers in Settings and the share dialog picker", () => { + writeWorkspaceReviewers([ + { id: "ws-rev-jordan-lee", name: "Jordan Lee", persona: "CTO" }, + ]); + readWorkspaceReviewers(); + try { + const settingsHtml = renderToStaticMarkup(); + expect(settingsHtml).toContain("Jordan Lee"); + expect(settingsHtml).toContain( + 'CTO', + ); + expect(settingsHtml).toContain("Remove"); + const dialogHtml = renderToStaticMarkup( + {}} + onCancel={() => {}} + onFault={() => {}} + />, + ); + expect(dialogHtml).toContain( + 'aria-label="Reviewer 1 Pick from workspace"', + ); + expect(dialogHtml).toContain("Jordan Lee (CTO)"); + } finally { + writeWorkspaceReviewers([]); + readWorkspaceReviewers(); + } + }); + test("renders WhatsNewDialog with Slide Decks and Approvals tabs, where Slide Decks is built-in and only Approvals is Experimental", () => { const slideDecksHtml = renderToStaticMarkup( { return typeof value === "object" && value !== null; @@ -94,13 +68,13 @@ function parseReviewers(raw: string | null): readonly WorkspaceReviewer[] { if (raw === cachedReviewersRaw) return cachedReviewersList; cachedReviewersRaw = raw; if (raw === null) { - cachedReviewersList = DEFAULT_WORKSPACE_REVIEWERS; + cachedReviewersList = []; return cachedReviewersList; } try { const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) { - cachedReviewersList = DEFAULT_WORKSPACE_REVIEWERS; + cachedReviewersList = []; return cachedReviewersList; } const valid: WorkspaceReviewer[] = []; @@ -114,7 +88,7 @@ function parseReviewers(raw: string | null): readonly WorkspaceReviewer[] { return cachedReviewersList; } catch (error) { console.warn("Invalid stored workspace reviewers JSON.", error); - cachedReviewersList = DEFAULT_WORKSPACE_REVIEWERS; + cachedReviewersList = []; return cachedReviewersList; } } @@ -226,7 +200,7 @@ export function useWorkspaceReviewers(): readonly WorkspaceReviewer[] { return useSyncExternalStore( subscribeReviewers, readWorkspaceReviewers, - () => DEFAULT_WORKSPACE_REVIEWERS, + () => cachedReviewersList, ); } diff --git a/docs/DEV.md b/docs/DEV.md index 0361753d..21afd734 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -112,7 +112,8 @@ it to one more than the previous entry's. Delete this paragraph after that date. workspace tenant in Postgres, or creating a local development workspace if the database is empty) with up to three realistic dummy projects across any stage (`idea`, `brief`, `planning`, `design_options`, `designed`, `ready_to_build`, -`shipped`): +`shipped`), and gives a workspace with no saved reviewers three demo +reviewers: ```sh bun run dev:seed # all 3 fixtures at their default stages diff --git a/scripts/seed-projects.ts b/scripts/seed-projects.ts index 1c24c9a8..9db5ff4c 100644 --- a/scripts/seed-projects.ts +++ b/scripts/seed-projects.ts @@ -12,6 +12,7 @@ import { ApprovalRound, type ApprovalRound as ApprovalRoundType, type ExpiryPreset, + type WorkspaceReviewer, } from "@corbits-builder/schemas/approval-round"; import { AppBundle, @@ -58,6 +59,7 @@ import { SEED_FIXTURE_IDS, SEED_PROJECT_FIXTURES, SEED_STAGES, + SEED_WORKSPACE_REVIEWERS, getSeedProjectFixture, loadSeedProjectFixture, loadSeedProjectFixtures, @@ -168,6 +170,16 @@ const approvalRoundTable = pgTable("sb_approval_round", { .defaultNow(), }); +const settingsTable = pgTable( + "sb_settings", + { + tenantId: text("tenant_id").notNull(), + scopeKey: text("scope_key").notNull(), + fields: jsonb("fields").notNull(), + }, + (table) => [primaryKey({ columns: [table.tenantId, table.scopeKey] })], +); + export interface ResolvedWorkspaceTenant { tenantId: string; principalId: string; @@ -196,6 +208,10 @@ export interface SeedApprovalRoundInput { export interface SeedDeps { resolveWorkspaceTenant(tenantId?: string): Promise; + seedWorkspaceReviewers( + workspace: ResolvedWorkspaceTenant, + reviewers: readonly WorkspaceReviewer[], + ): Promise; createProject( workspace: ResolvedWorkspaceTenant, input: { @@ -283,6 +299,7 @@ export async function seedDevelopmentProjects( const approve: boolean = options.approve ?? true; const fixtures = await loadSeedProjectFixtures(selector); const workspace = await deps.resolveWorkspaceTenant(options.tenantId); + await deps.seedWorkspaceReviewers(workspace, SEED_WORKSPACE_REVIEWERS); const projects: SeededProjectSummary[] = []; @@ -670,6 +687,21 @@ export function createDatabaseSeedDeps( return resolveOrCreateWorkspaceTenant(db, principalKeyStore, tenantId); }, + async seedWorkspaceReviewers(workspace, reviewers) { + await db + .insert(settingsTable) + .values({ + tenantId: workspace.tenantId, + scopeKey: "workspace:reviewers", + fields: { + providerNames: { value: null, version: 0 }, + title: { value: null, version: 0 }, + reviewers, + }, + }) + .onConflictDoNothing(); + }, + async createProject(workspace, input) { const projectId = crypto.randomUUID(); const child = await provisionChildTenant( diff --git a/test/integration/seed-projects.test.ts b/test/integration/seed-projects.test.ts index b699bd4a..1b3036f3 100644 --- a/test/integration/seed-projects.test.ts +++ b/test/integration/seed-projects.test.ts @@ -63,6 +63,8 @@ function createInMemorySeedDeps(): SeedDeps { }; }, + async seedWorkspaceReviewers() {}, + async createProject(workspace, input) { const projectId = `project-${input.fixtureId}`; projects.set(projectId, { diff --git a/test/seed/index.ts b/test/seed/index.ts index a6a6e004..46e59109 100644 --- a/test/seed/index.ts +++ b/test/seed/index.ts @@ -6,6 +6,7 @@ import type { ExpiryPreset, ReviewerDecision, TriageItemStatus, + WorkspaceReviewer, } from "@corbits-builder/schemas/approval-round"; import { ApprovalPackageBundle, @@ -44,6 +45,30 @@ export type SeedFixtureId = (typeof SEED_FIXTURE_IDS)[number]; export type SeedFixtureSelector = SeedFixtureId | "all"; +export const SEED_WORKSPACE_REVIEWERS: readonly WorkspaceReviewer[] = [ + { + id: "ws-rev-dana-chen", + name: "Dana Chen", + persona: "CEO", + email: "dana.chen@example.com", + goal: "Approve Aurora Weather Studio scope, timeline, and Q3 budget to begin the Coder phase", + }, + { + id: "ws-rev-marcus-vance", + name: "Marcus Vance", + persona: "CTO", + email: "marcus.vance@example.com", + goal: "Sign off on radar tile payload budget, latency SLAs, and offline caching architecture", + }, + { + id: "ws-rev-priya-patel", + name: "Priya Patel", + persona: "VP Product", + email: "priya.patel@example.com", + goal: "Confirm user journey, severe alert triage flow, and accessibility targets", + }, +]; + export type SeedDesignOptionMetadata = Readonly<{ conceptId: string; title: string; From 864e3377768c95e4c9a962f4f1be5c8f154d7d8b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 5 Nov 2026 20:01:12 -0800 Subject: [PATCH 2/2] fix(hub): surface an unreadable workspace reviewers setting --- apps/hub/src/artifacts/approval-rounds.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/hub/src/artifacts/approval-rounds.ts b/apps/hub/src/artifacts/approval-rounds.ts index 51424dff..bbdc019a 100644 --- a/apps/hub/src/artifacts/approval-rounds.ts +++ b/apps/hub/src/artifacts/approval-rounds.ts @@ -471,9 +471,12 @@ export function createPgApprovalRoundStore( eq(settingsRecord.scopeKey, WORKSPACE_REVIEWERS_SCOPE_KEY), ), ); - return row === undefined - ? [] - : (parseWorkspaceReviewersField(row.fields) ?? []); + if (row === undefined) return []; + const reviewers = parseWorkspaceReviewersField(row.fields); + // Every writer stores the shape read here, so a row that does not parse is a bug to surface, not "no reviewers". + if (reviewers === null) + throw new Error("Workspace reviewers setting is not readable"); + return reviewers; } return {