Skip to content

Commit bed75ae

Browse files
authored
Merge pull request #238 from httpsphl/feat/welcome-ideas
feat(app): tap an idea on the welcome screen
2 parents 22f0f14 + b00d543 commit bed75ae

30 files changed

Lines changed: 292 additions & 18 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Check } from "lucide-react";
2+
import { useT } from "../../i18n";
3+
import { AVATAR_PALETTE } from "../../lib/protocol.gen";
4+
5+
/** Swatches for a crew's color, and a way to have none. */
6+
export function CrewColorField({
7+
value,
8+
onChange,
9+
}: {
10+
value: string | null;
11+
onChange(color: string | null): void;
12+
}) {
13+
const words = useT().crews.dialog;
14+
const same = (a: string | null, b: string | null) => a?.toLowerCase() === b?.toLowerCase();
15+
return (
16+
<fieldset className="m-0 flex flex-col gap-1.5 border-0 p-0">
17+
<legend className="mb-1.5 font-medium text-ink-soft text-sm">{words.color}</legend>
18+
<div className="flex flex-wrap items-center gap-1.5">
19+
<button
20+
type="button"
21+
aria-pressed={value === null}
22+
onClick={() => onChange(null)}
23+
className={`rounded-lg px-2 py-1 font-medium text-xs ${value === null ? "bg-sunken text-ink ring-2 ring-accent" : "text-muted hover:text-ink"}`}
24+
>
25+
{words.noColor}
26+
</button>
27+
{AVATAR_PALETTE.map((swatch) => {
28+
const on = same(value, swatch);
29+
return (
30+
<button
31+
key={swatch}
32+
type="button"
33+
aria-label={words.swatch(swatch)}
34+
aria-pressed={on}
35+
onClick={() => onChange(swatch)}
36+
className={`relative grid size-7 place-items-center rounded-full ${on ? "ring-2 ring-accent ring-offset-2 ring-offset-panel" : ""}`}
37+
style={{ background: swatch }}
38+
>
39+
{on && <Check aria-hidden size={14} className="text-black/70" />}
40+
</button>
41+
);
42+
})}
43+
</div>
44+
</fieldset>
45+
);
46+
}

‎app/src/features/crews/CrewDialog.tsx‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,37 @@ import { SelectField, TextArea, TextField } from "../../ui/Field";
99
import { notifyError } from "../../ui/toast";
1010
import { useCatalog } from "../catalog/useCatalog";
1111
import { addTemplateBots } from "./addTemplateBots";
12+
import { CrewColorField } from "./CrewColorField";
1213
import type { CrewTemplate } from "./crewTemplates";
1314
import { PickedTemplate, TemplatePicker } from "./TemplatePicker";
1415
import { WorkFolderField } from "./WorkFolderField";
1516

1617
const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];
1718

18-
/** Creates a crew with its chief, or renames `crew`. */
19-
export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void }) {
19+
/**
20+
* Creates a crew with its chief, or renames `crew`. `start` opens a new crew
21+
* already on that template, as when the owner taps an idea on the welcome
22+
* screen.
23+
*/
24+
export function CrewDialog({
25+
crew,
26+
start,
27+
onClose,
28+
}: {
29+
crew?: Crew;
30+
start?: CrewTemplate | undefined;
31+
onClose(): void;
32+
}) {
2033
const t = useT();
2134
const api = useApi();
2235
const putCrew = useApp((state) => state.putCrew);
2336
const selectCrew = useApp((state) => state.selectCrew);
24-
const [name, setName] = useState(crew?.name ?? "");
37+
const [name, setName] = useState(
38+
crew?.name ?? (start ? t.crewTemplates.items[start.id].name : ""),
39+
);
40+
const [color, setColor] = useState<string | null>(crew?.color ?? null);
2541
const [folder, setFolder] = useState<string | null>(null);
26-
const [goal, setGoal] = useState("");
42+
const [goal, setGoal] = useState(start ? t.crewTemplates.items[start.id].goal : "");
2743
const [chiefModel, setChiefModel] = useState<BotModel>("default");
2844
const selectBot = useApp((state) => state.selectBot);
2945
const putBot = useApp((state) => state.putBot);
@@ -32,7 +48,7 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
3248
// A new crew starts from a template (spec 29.1) when the catalog loaded:
3349
// `undefined` is the list of templates, `null` an empty crew.
3450
const { roles, failed: catalogFailed } = useCatalog(!crew);
35-
const [template, setTemplate] = useState<CrewTemplate | null | undefined>(undefined);
51+
const [template, setTemplate] = useState<CrewTemplate | null | undefined>(start);
3652
const loading = !crew && roles === null && !catalogFailed;
3753
const choosing = !crew && template === undefined && roles !== null;
3854

@@ -49,7 +65,7 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
4965
setError(null);
5066
try {
5167
const d = t.crews.dialog;
52-
const saved = crew
68+
let saved = crew
5369
? await api.call("crews.rename", { crewId: crew.id, name })
5470
: await api.call("crews.create", {
5571
name,
@@ -62,6 +78,9 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
6278
...(chiefModel !== "default" && { model: chiefModel }),
6379
},
6480
});
81+
if (crew && color !== crew.color) {
82+
saved = await api.call("crews.setColor", { crewId: crew.id, color });
83+
}
6584
putCrew(saved);
6685
if (!crew) {
6786
selectCrew(saved.id);
@@ -134,6 +153,7 @@ export function CrewDialog({ crew, onClose }: { crew?: Crew; onClose(): void })
134153
autoFocus
135154
required
136155
/>
156+
{crew && <CrewColorField value={color} onChange={setColor} />}
137157
{!crew && (
138158
<>
139159
<TextArea

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ function CrewEntry({ crew }: { crew: Crew }) {
127127
}}
128128
className={`${row} h-8 gap-2 pr-2.5 pl-8 font-semibold text-sm ${selected || menuAt ? "bg-sunken" : ""}`}
129129
>
130+
{crew.color && (
131+
<span
132+
aria-hidden
133+
data-testid="crew-color"
134+
className="size-2.5 shrink-0 rounded-full"
135+
style={{ background: crew.color }}
136+
/>
137+
)}
130138
<span className="min-w-0 flex-1 truncate">{crew.name}</span>
131139
{waiting && (
132140
<span className="flex shrink-0">
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, renderApp, sidebar } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
describe("a crew's color", () => {
9+
test("the owner picks one in the rename dialog and it shows beside the crew", async () => {
10+
const fake = new FakeBotloft();
11+
const crew = fake.addCrew("Ops");
12+
fake.addBot(crew.id, "Scout");
13+
renderApp(fake);
14+
await crewOpened("Ops");
15+
expect(within(sidebar()).queryByTestId("crew-color")).toBeNull();
16+
17+
fireEvent.contextMenu(within(sidebar()).getByRole("button", { name: /^Ops/ }), {
18+
clientX: 100,
19+
clientY: 100,
20+
});
21+
const menu = screen.getByRole("menu", { name: "Actions for Ops" });
22+
fireEvent.click(within(menu).getByRole("menuitem", { name: "Rename" }));
23+
const dialog = await screen.findByRole("dialog");
24+
fireEvent.click(within(dialog).getByRole("button", { name: "Color #5EC8FF" }));
25+
fireEvent.click(within(dialog).getByRole("button", { name: "Rename" }));
26+
27+
const dot = await within(sidebar()).findByTestId("crew-color");
28+
expect(dot.style.background).toMatch(/#5ec8ff|rgb\(94, 200, 255\)/i);
29+
expect(fake.crew(crew.id).color).toBe("#5EC8FF");
30+
});
31+
});

‎app/src/features/onboarding/Welcome.tsx‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@ import { Button } from "../../ui/Button";
88
import { useDaemonSettings } from "../account/useDaemonSettings";
99
import { BotAvatar } from "../bots/BotAvatar";
1010
import { CrewDialog } from "../crews/CrewDialog";
11+
import { CREW_TEMPLATES, type CrewTemplate } from "../crews/crewTemplates";
1112
import { ClaudeCodeHelp } from "./ClaudeCodeHelp";
1213
import { SignInButton } from "./SignIn";
1314

15+
/** Templates shown as ideas on the first screen. */
16+
const IDEAS = 4;
17+
1418
/** First run: what is ready, what is not, and the first crew. */
1519
export function Welcome() {
16-
const w = useT().onboarding.welcome;
20+
const t = useT();
21+
const w = t.onboarding.welcome;
22+
const ideas = t.crewTemplates.items;
1723
const system = useApp((state) => state.system);
1824
const [creating, setCreating] = useState(false);
25+
const [idea, setIdea] = useState<CrewTemplate | undefined>(undefined);
1926
const whenClosed = usePref(prefs.whenClosed);
2027
const { settings } = useDaemonSettings();
2128
const running =
@@ -82,11 +89,42 @@ export function Welcome() {
8289
<p className="font-semibold">{w.askFirstTitle}</p>
8390
<p className="mt-1 text-ink-soft">{w.askFirstBody}</p>
8491
</div>
85-
<Button className="mt-6" variant="primary" icon={Plus} onClick={() => setCreating(true)}>
92+
<section aria-label={w.ideasTitle} className="mt-6">
93+
<h2 className="font-semibold text-sm">{w.ideasTitle}</h2>
94+
<ul className="m-0 mt-2 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
95+
{CREW_TEMPLATES.slice(0, IDEAS).map((template) => {
96+
const item = ideas[template.id];
97+
return (
98+
<li key={template.id} className="flex">
99+
<button
100+
type="button"
101+
onClick={() => {
102+
setIdea(template);
103+
setCreating(true);
104+
}}
105+
className="flex w-full flex-col gap-0.5 rounded-xl border border-line bg-panel p-3 text-left transition-colors hover:border-line-strong hover:bg-sunken"
106+
>
107+
<span className="font-semibold text-sm">{item.name}</span>
108+
<span className="line-clamp-2 text-ink-soft text-sm">{item.summary}</span>
109+
</button>
110+
</li>
111+
);
112+
})}
113+
</ul>
114+
</section>
115+
<Button
116+
className="mt-4"
117+
variant="primary"
118+
icon={Plus}
119+
onClick={() => {
120+
setIdea(undefined);
121+
setCreating(true);
122+
}}
123+
>
86124
{w.createCrew}
87125
</Button>
88126
</div>
89-
{creating && <CrewDialog onClose={() => setCreating(false)} />}
127+
{creating && <CrewDialog start={idea} onClose={() => setCreating(false)} />}
90128
</main>
91129
);
92130
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { renderApp } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
describe("ideas on the welcome screen", () => {
9+
test("tapping an idea opens a new crew already set up from that template", async () => {
10+
renderApp(new FakeBotloft());
11+
const ideas = await screen.findByRole("region", { name: "Or tap an idea" });
12+
expect(within(ideas).getAllByRole("listitem")).toHaveLength(4);
13+
14+
fireEvent.click(within(ideas).getByRole("button", { name: /Software team/ }));
15+
const dialog = await screen.findByRole("dialog", { name: "New crew" });
16+
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("Software team");
17+
expect(within(dialog).getByText(/This team adds/)).toBeDefined();
18+
});
19+
20+
test("the plain button still starts at the list of templates", async () => {
21+
renderApp(new FakeBotloft());
22+
fireEvent.click(await screen.findByRole("button", { name: "Create your first crew" }));
23+
expect(await screen.findByRole("dialog", { name: "Start from a template" })).toBeDefined();
24+
});
25+
});

‎app/src/i18n/en/crews.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export const crews = {
4343
noBots: "No bots yet",
4444
},
4545
dialog: {
46+
color: "Color",
47+
noColor: "No color",
48+
swatch: (color: string) => `Color ${color}`,
4649
folder: "Work folder",
4750
folderHint: "Where the bots put what they make. It can be a folder you already use.",
4851
folderDefault: "A new folder inside Botloft",

‎app/src/i18n/en/onboarding.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const onboarding = {
6565
askFirstTitle: "Bots ask before they change things",
6666
askFirstBody:
6767
"When a bot wants to run a command or edit a file, it asks in its chat and waits for you to allow or deny it.",
68+
ideasTitle: "Or tap an idea",
6869
createCrew: "Create your first crew",
6970
},
7071
claudeCode: {

‎app/src/i18n/es/crews.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export const crews: Messages["crews"] = {
3535
noBots: "Ningún bot todavía",
3636
},
3737
dialog: {
38+
color: "Color",
39+
noColor: "Sin color",
40+
swatch: (color: string) => `Color ${color}`,
3841
folder: "Carpeta de trabajo",
3942
folderHint: "Donde los bots guardan lo que hacen. Puede ser una carpeta que ya usas.",
4043
folderDefault: "Una carpeta nueva dentro de Botloft",

‎app/src/i18n/es/onboarding.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const onboarding: Messages["onboarding"] = {
6565
askFirstTitle: "Los bots preguntan antes de cambiar cosas",
6666
askFirstBody:
6767
"Cuando un bot quiere ejecutar un comando o editar un archivo, lo pregunta en su chat y espera a que lo permitas o lo deniegues.",
68+
ideasTitle: "O toca una idea",
6869
createCrew: "Crear tu primer equipo",
6970
},
7071
claudeCode: {

0 commit comments

Comments
 (0)