From efa30972869a2bc500f895fd56ae027392c36bf5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 19:21:38 -0800 Subject: [PATCH 1/4] feat(web): ask which profile is the default --- apps/web/src/config.ts | 4 + apps/web/src/pages/profiles.test.ts | 61 +++++++++++ apps/web/src/pages/profiles.tsx | 151 +++++++++++++++++++++++++++- 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/pages/profiles.test.ts diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 29d140b6..7ac3bf2b 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -985,6 +985,10 @@ export const wording = { model: (tier: string) => `${tier} model`, effort: (tier: string) => `${tier} effort`, pick: "Model profile", + askDefault: "Which profile is the default?", + askDefaultLead: + "New projects start on the profile you pick. The one you just saved is in the list.", + askDefaultSave: "Set default", }, settings: { title: "Settings", diff --git a/apps/web/src/pages/profiles.test.ts b/apps/web/src/pages/profiles.test.ts new file mode 100644 index 00000000..8b3d85f0 --- /dev/null +++ b/apps/web/src/pages/profiles.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { + activeProfileId, + asksDefaultProfile, + defaultProfileOptions, + initialDefaultProfile, +} from "./profiles.tsx"; + +test("saving a second profile asks which is the default and stores that id", () => { + expect(asksDefaultProfile("new", 1)).toBe(true); + expect(asksDefaultProfile("new", 2)).toBe(true); + const options = defaultProfileOptions( + [ + { id: "shipped", label: "Shipped" }, + { id: "custom_old", label: "Old" }, + ], + { id: "custom_new", label: "Mine" }, + ); + expect(options.map((option) => option.id)).toEqual([ + "shipped", + "custom_old", + "custom_new", + ]); + expect( + activeProfileId({ + ask: true, + previous: "shipped", + chosen: "custom_new", + }), + ).toBe("custom_new"); + expect( + initialDefaultProfile({ + previous: "shipped", + automatic: "shipped", + createdId: "custom_new", + existingIds: ["shipped", "custom_old"], + }), + ).toBe("shipped"); +}); + +test("creating the first profile does not ask and keeps the previous active id", () => { + expect(asksDefaultProfile("new", 0)).toBe(false); + expect( + activeProfileId({ + ask: false, + previous: undefined, + chosen: "custom_new", + }), + ).toBeUndefined(); +}); + +test("editing a profile does not ask again", () => { + expect(asksDefaultProfile("edit", 3)).toBe(false); + expect( + activeProfileId({ + ask: false, + previous: "shipped", + chosen: "custom_new", + }), + ).toBe("shipped"); +}); diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/profiles.tsx index 1139688f..76219e57 100644 --- a/apps/web/src/pages/profiles.tsx +++ b/apps/web/src/pages/profiles.tsx @@ -89,13 +89,63 @@ function complete(draft: Draft, tiers: readonly Tier[]): Tiers | null { return done as Tiers; } +/** A new profile asks which is the default only when another profile already exists. */ +export function asksDefaultProfile( + mode: "new" | "edit", + existingCount: number, +): boolean { + return mode === "new" && existingCount > 0; +} + +/** The new profile plus the ones already on the screen, for the default question. */ +export function defaultProfileOptions( + existing: readonly { id: string; label: string }[], + created: { id: string; label: string }, +): { id: string; label: string }[] { + return [ + ...existing.map((entry) => ({ id: entry.id, label: entry.label })), + { id: created.id, label: created.label }, + ]; +} + +/** Which id the select shows before the person answers. */ +export function initialDefaultProfile(input: { + previous: string | undefined; + automatic: string | undefined; + createdId: string; + existingIds: readonly string[]; +}): string { + const known = new Set([...input.existingIds, input.createdId]); + return ( + [input.previous, input.automatic, input.createdId].find( + (id) => id !== undefined && known.has(id), + ) ?? input.createdId + ); +} + +/** An answered ask stores the chosen id. Otherwise the previous active id stays. */ +export function activeProfileId(input: { + ask: boolean; + previous: string | undefined; + chosen: string | undefined; +}): string | undefined { + return input.ask ? input.chosen : input.previous; +} + export function Profiles({ initialField }: { initialField?: Field } = {}) { + const catalog = useModelCatalog(); const queryClient = useQueryClient(); const [field, setField] = useState(initialField ?? null); const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); + const [defaultAsk, setDefaultAsk] = useState<{ + options: readonly { id: string; label: string }[]; + list: CustomProfile[]; + } | null>(null); + const [defaultId, setDefaultId] = useState(""); + const [defaultBusy, setDefaultBusy] = useState(false); useEffect(() => { if (initialField) return; void workspaceTenantId() @@ -292,10 +342,109 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { entry.id === profile.id ? profile : entry, ) : [...custom, profile]; - if (await save(choice(active?.id, list))) setEditing(null); + const ask = asksDefaultProfile(editing.mode, profiles.length); + const saved = await save( + choice( + activeProfileId({ + ask: false, + previous: field.value?.profile, + chosen: undefined, + }), + list, + ), + ); + if (!saved) return; + setEditing(null); + if (!ask) return; + setDefaultId( + initialDefaultProfile({ + previous: field.value?.profile, + automatic: data.automaticProfile, + createdId: profile.id, + existingIds: profiles.map((entry) => entry.id), + }), + ); + setDefaultBusy(false); + setDefaultAsk({ + options: defaultProfileOptions(profiles, profile), + list, + }); + }} /> ) : null} + {defaultAsk ? ( + + open || defaultBusy ? undefined : setDefaultAsk(null) + } + > + + + {copy.askDefault} + {copy.askDefaultLead} + +
{ + event.preventDefault(); + if (!defaultId || defaultBusy) return; + setDefaultBusy(true); + void save( + choice( + activeProfileId({ + ask: true, + previous: field.value?.profile, + chosen: defaultId, + }), + defaultAsk.list, + ), + ).then((ok) => { + setDefaultBusy(false); + if (ok) setDefaultAsk(null); + }); + }} + > + +
+ + {copy.pick} + + setDefaultId(event.target.value)} + > + {defaultAsk.options.map((option) => ( + + ))} + +
+
+ + + + +
+
+
+ ) : null} ); } From e69990c392baecd21e052b7bc287a3c87c9fc902 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 19:30:20 -0800 Subject: [PATCH 2/4] fix(web): save a new profile only after the default is chosen --- apps/web/src/config.ts | 3 +- apps/web/src/pages/profiles.test.ts | 61 ----------------- apps/web/src/pages/profiles.tsx | 101 ++++++---------------------- 3 files changed, 23 insertions(+), 142 deletions(-) delete mode 100644 apps/web/src/pages/profiles.test.ts diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 7ac3bf2b..4354e11c 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -986,8 +986,7 @@ export const wording = { effort: (tier: string) => `${tier} effort`, pick: "Model profile", askDefault: "Which profile is the default?", - askDefaultLead: - "New projects start on the profile you pick. The one you just saved is in the list.", + askDefaultLead: "New projects start on the one you pick.", askDefaultSave: "Set default", }, settings: { diff --git a/apps/web/src/pages/profiles.test.ts b/apps/web/src/pages/profiles.test.ts deleted file mode 100644 index 8b3d85f0..00000000 --- a/apps/web/src/pages/profiles.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, test } from "bun:test"; -import { - activeProfileId, - asksDefaultProfile, - defaultProfileOptions, - initialDefaultProfile, -} from "./profiles.tsx"; - -test("saving a second profile asks which is the default and stores that id", () => { - expect(asksDefaultProfile("new", 1)).toBe(true); - expect(asksDefaultProfile("new", 2)).toBe(true); - const options = defaultProfileOptions( - [ - { id: "shipped", label: "Shipped" }, - { id: "custom_old", label: "Old" }, - ], - { id: "custom_new", label: "Mine" }, - ); - expect(options.map((option) => option.id)).toEqual([ - "shipped", - "custom_old", - "custom_new", - ]); - expect( - activeProfileId({ - ask: true, - previous: "shipped", - chosen: "custom_new", - }), - ).toBe("custom_new"); - expect( - initialDefaultProfile({ - previous: "shipped", - automatic: "shipped", - createdId: "custom_new", - existingIds: ["shipped", "custom_old"], - }), - ).toBe("shipped"); -}); - -test("creating the first profile does not ask and keeps the previous active id", () => { - expect(asksDefaultProfile("new", 0)).toBe(false); - expect( - activeProfileId({ - ask: false, - previous: undefined, - chosen: "custom_new", - }), - ).toBeUndefined(); -}); - -test("editing a profile does not ask again", () => { - expect(asksDefaultProfile("edit", 3)).toBe(false); - expect( - activeProfileId({ - ask: false, - previous: "shipped", - chosen: "custom_new", - }), - ).toBe("shipped"); -}); diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/profiles.tsx index 76219e57..b2497b35 100644 --- a/apps/web/src/pages/profiles.tsx +++ b/apps/web/src/pages/profiles.tsx @@ -89,49 +89,6 @@ function complete(draft: Draft, tiers: readonly Tier[]): Tiers | null { return done as Tiers; } -/** A new profile asks which is the default only when another profile already exists. */ -export function asksDefaultProfile( - mode: "new" | "edit", - existingCount: number, -): boolean { - return mode === "new" && existingCount > 0; -} - -/** The new profile plus the ones already on the screen, for the default question. */ -export function defaultProfileOptions( - existing: readonly { id: string; label: string }[], - created: { id: string; label: string }, -): { id: string; label: string }[] { - return [ - ...existing.map((entry) => ({ id: entry.id, label: entry.label })), - { id: created.id, label: created.label }, - ]; -} - -/** Which id the select shows before the person answers. */ -export function initialDefaultProfile(input: { - previous: string | undefined; - automatic: string | undefined; - createdId: string; - existingIds: readonly string[]; -}): string { - const known = new Set([...input.existingIds, input.createdId]); - return ( - [input.previous, input.automatic, input.createdId].find( - (id) => id !== undefined && known.has(id), - ) ?? input.createdId - ); -} - -/** An answered ask stores the chosen id. Otherwise the previous active id stays. */ -export function activeProfileId(input: { - ask: boolean; - previous: string | undefined; - chosen: string | undefined; -}): string | undefined { - return input.ask ? input.chosen : input.previous; -} - export function Profiles({ initialField }: { initialField?: Field } = {}) { const catalog = useModelCatalog(); @@ -342,33 +299,28 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { entry.id === profile.id ? profile : entry, ) : [...custom, profile]; - const ask = asksDefaultProfile(editing.mode, profiles.length); - const saved = await save( - choice( - activeProfileId({ - ask: false, - previous: field.value?.profile, - chosen: undefined, - }), + if (editing.mode === "new" && profiles.length > 0) { + const stored = field.value?.profile; + const preset = [stored, data.automaticProfile].find( + (id) => + id !== undefined && profiles.some((entry) => entry.id === id), + ); + setDefaultId(preset ?? profile.id); + setDefaultAsk({ + options: [ + ...profiles.map((entry) => ({ + id: entry.id, + label: entry.label, + })), + { id: profile.id, label: profile.label }, + ], list, - ), - ); - if (!saved) return; - setEditing(null); - if (!ask) return; - setDefaultId( - initialDefaultProfile({ - previous: field.value?.profile, - automatic: data.automaticProfile, - createdId: profile.id, - existingIds: profiles.map((entry) => entry.id), - }), - ); - setDefaultBusy(false); - setDefaultAsk({ - options: defaultProfileOptions(profiles, profile), - list, - }); + }); + setEditing(null); + return; + } + if (await save(choice(field.value?.profile, list))) + setEditing(null); }} /> @@ -390,16 +342,7 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { event.preventDefault(); if (!defaultId || defaultBusy) return; setDefaultBusy(true); - void save( - choice( - activeProfileId({ - ask: true, - previous: field.value?.profile, - chosen: defaultId, - }), - defaultAsk.list, - ), - ).then((ok) => { + void save(choice(defaultId, defaultAsk.list)).then((ok) => { setDefaultBusy(false); if (ok) setDefaultAsk(null); }); From d3bd6004391debbc0e8c1529330d9cbd1c1b8904 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 19:58:44 -0800 Subject: [PATCH 3/4] fix(web): toggle a new profile as the default --- apps/web/src/config.ts | 5 +- apps/web/src/pages/profiles.tsx | 141 +++++++++----------------------- 2 files changed, 41 insertions(+), 105 deletions(-) diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 4354e11c..ee2ea2ea 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -985,9 +985,8 @@ export const wording = { model: (tier: string) => `${tier} model`, effort: (tier: string) => `${tier} effort`, pick: "Model profile", - askDefault: "Which profile is the default?", - askDefaultLead: "New projects start on the one you pick.", - askDefaultSave: "Set default", + replaceDefault: (name: string) => + `Do you want to make this your new default profile? It will replace ${name}.`, }, settings: { title: "Settings", diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/profiles.tsx index b2497b35..16a0e62f 100644 --- a/apps/web/src/pages/profiles.tsx +++ b/apps/web/src/pages/profiles.tsx @@ -8,7 +8,7 @@ * project keeps the bindings it started with, so a change here never alters a running project. */ import { wording } from "../config.ts"; -import { Input, Section } from "@corbits/react-ui"; +import { Input, Section, Switch } from "@corbits/react-ui"; import { Dialog, DialogBody, @@ -97,12 +97,6 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); - const [defaultAsk, setDefaultAsk] = useState<{ - options: readonly { id: string; label: string }[]; - list: CustomProfile[]; - } | null>(null); - const [defaultId, setDefaultId] = useState(""); - const [defaultBusy, setDefaultBusy] = useState(false); useEffect(() => { if (initialField) return; void workspaceTenantId() @@ -292,102 +286,25 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { )} editing={editing} onClose={() => setEditing(null)} - onSave={async (profile) => { + replacing={editing.mode === "new" ? (active?.label ?? null) : null} + onSave={async (profile, makeDefault) => { const list = editing.mode === "edit" ? custom.map((entry) => entry.id === profile.id ? profile : entry, ) : [...custom, profile]; - if (editing.mode === "new" && profiles.length > 0) { - const stored = field.value?.profile; - const preset = [stored, data.automaticProfile].find( - (id) => - id !== undefined && profiles.some((entry) => entry.id === id), - ); - setDefaultId(preset ?? profile.id); - setDefaultAsk({ - options: [ - ...profiles.map((entry) => ({ - id: entry.id, - label: entry.label, - })), - { id: profile.id, label: profile.label }, - ], - list, - }); - setEditing(null); - return; - } - if (await save(choice(field.value?.profile, list))) - setEditing(null); + const profileId = + editing.mode === "new" && makeDefault + ? profile.id + : editing.mode === "new" + ? (active?.id ?? field.value?.profile) + : field.value?.profile; + if (await save(choice(profileId, list))) setEditing(null); }} /> ) : null} - {defaultAsk ? ( - - open || defaultBusy ? undefined : setDefaultAsk(null) - } - > - - - {copy.askDefault} - {copy.askDefaultLead} - -
{ - event.preventDefault(); - if (!defaultId || defaultBusy) return; - setDefaultBusy(true); - void save(choice(defaultId, defaultAsk.list)).then((ok) => { - setDefaultBusy(false); - if (ok) setDefaultAsk(null); - }); - }} - > - -
- - {copy.pick} - - setDefaultId(event.target.value)} - > - {defaultAsk.options.map((option) => ( - - ))} - -
-
- - - - -
-
-
- ) : null} ); } @@ -403,20 +320,24 @@ function ProfileEditor({ catalog, profiles, editing, + replacing, onClose, onSave, }: { catalog: ModelCatalog; profiles: readonly Profile[]; editing: Editing; + /** Label of the current default, when a new profile can replace it. */ + replacing: string | null; onClose: () => void; - onSave: (profile: CustomProfile) => Promise; + onSave: (profile: CustomProfile, makeDefault: boolean) => Promise; }) { const [draft, setDraft] = useState( editing.mode === "edit" ? { label: editing.profile.label, tiers: editing.profile.tiers } : { label: "", tiers: {} }, ); + const [makeDefault, setMakeDefault] = useState(false); const [busy, setBusy] = useState(false); const filled = complete( draft, @@ -449,14 +370,17 @@ function ProfileEditor({ event.preventDefault(); if (!ready || busy) return; setBusy(true); - void onSave({ - id: - editing.mode === "edit" - ? editing.profile.id - : `custom_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`, - label: draft.label.trim(), - tiers: filled, - }).finally(() => setBusy(false)); + void onSave( + { + id: + editing.mode === "edit" + ? editing.profile.id + : `custom_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`, + label: draft.label.trim(), + tiers: filled, + }, + replacing !== null && makeDefault, + ).finally(() => setBusy(false)); }} > @@ -590,6 +514,19 @@ function ProfileEditor({ ); })} + {replacing ? ( +
+ + {copy.replaceDefault(replacing)} + + +
+ ) : null}