Skip to content

Commit d5e3e43

Browse files
authored
Merge pull request #14 from httpsphl/feat/languages
The app in English, Portuguese and Spanish, with a language picker
2 parents 99b1898 + 023e186 commit d5e3e43

72 files changed

Lines changed: 2161 additions & 412 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon
5757
- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`). Tempo em ms Unix.
5858
- Tudo que é específico de Windows fica em `crates/botloftd/src/platform/`. O resto do daemon não chama Win32 direto.
5959
- A UI depende só da interface `BotloftApi` (`app/src/lib/api.ts`). Testes de componente usam `FakeBotloft`.
60+
- Nenhum texto de interface escrito direto no componente: tudo vai em `app/src/i18n/{en,pt-BR,es}/<área>.ts` e é lido com `useT()` (ou `t()` fora do React). Texto novo entra nos três idiomas no mesmo commit; o TypeScript acusa o que faltar (spec 15.6). Palavras simples, sem "daemon" nem outro jargão.
6061
- Nunca edite `app/src/lib/protocol.gen.ts` à mão.
6162
- Commits pequenos, no formato Conventional Commits (`feat(courier): ...`).
6263

‎app/src-tauri/tauri.bundle.conf.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"windows": {
77
"nsis": {
88
"installMode": "currentUser",
9+
"languages": ["English", "PortugueseBR", "Spanish"],
910
"installerHooks": "./windows/hooks.nsh"
1011
}
1112
}

‎app/src/features/bots/BotDialog.tsx‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Check } from "lucide-react";
22
import { type FormEvent, useState } from "react";
3+
import { useT } from "../../i18n";
34
import { errorText } from "../../lib/api";
45
import { AVATAR_PALETTE, type Bot, type CrewId, FIELD_LIMITS } from "../../lib/protocol.gen";
56
import { useApi, useApp } from "../../store/context";
@@ -12,6 +13,7 @@ type Props = { onClose(): void } & ({ crewId: CrewId; bot?: undefined } | { bot:
1213

1314
/** Creates a bot in `crewId`, or edits `bot`. */
1415
export function BotDialog(props: Props) {
16+
const t = useT();
1517
const api = useApi();
1618
const putBot = useApp((state) => state.putBot);
1719
const selectBot = useApp((state) => state.selectBot);
@@ -58,59 +60,57 @@ export function BotDialog(props: Props) {
5860
const formId = "bot-form";
5961
return (
6062
<Dialog
61-
title={editing ? `Edit ${editing.name}` : "New bot"}
63+
title={editing ? t.bots.dialog.editTitle(editing.name) : t.bots.dialog.newTitle}
6264
onClose={props.onClose}
6365
width="lg"
6466
footer={
6567
<>
66-
<Button onClick={props.onClose}>Cancel</Button>
68+
<Button onClick={props.onClose}>{t.common.cancel}</Button>
6769
<Button variant="primary" type="submit" form={formId} disabled={busy}>
68-
{editing ? "Save" : "Create bot"}
70+
{editing ? t.bots.dialog.save : t.bots.dialog.create}
6971
</Button>
7072
</>
7173
}
7274
>
7375
<form id={formId} onSubmit={submit} className="flex flex-col gap-4">
7476
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
7577
<TextField
76-
label="Name"
78+
label={t.bots.dialog.name}
7779
value={name}
7880
max={FIELD_LIMITS.name}
7981
onChange={(event) => setName(event.target.value)}
80-
placeholder="Reviewer"
81-
hint="Other bots reach it by the handle made from this name."
82+
placeholder={t.bots.dialog.namePlaceholder}
83+
hint={t.bots.dialog.nameHint}
8284
autoFocus
8385
required
8486
/>
8587
<TextField
86-
label="Role"
88+
label={t.bots.dialog.role}
8789
value={role}
8890
max={FIELD_LIMITS.role}
8991
onChange={(event) => setRole(event.target.value)}
90-
placeholder="Reviews pull requests before they merge"
92+
placeholder={t.bots.dialog.rolePlaceholder}
9193
/>
9294
</div>
9395
<TextArea
94-
label="Instructions"
96+
label={t.bots.dialog.instructions}
9597
value={instructions}
9698
max={FIELD_LIMITS.instructions}
9799
rows={8}
98100
onChange={(event) => setInstructions(event.target.value)}
99-
placeholder="How this bot works, what it may do on its own and when to ask."
100-
hint={
101-
editing
102-
? "Saved to the bot's rules now; the bot reads them the next time it starts."
103-
: undefined
104-
}
101+
placeholder={t.bots.dialog.instructionsPlaceholder}
102+
hint={editing ? t.bots.dialog.instructionsHint : undefined}
105103
/>
106104
<fieldset className="flex flex-col gap-1.5">
107-
<legend className="mb-1.5 font-medium text-ink-soft text-sm">Color</legend>
105+
<legend className="mb-1.5 font-medium text-ink-soft text-sm">
106+
{t.bots.dialog.color}
107+
</legend>
108108
<div className="flex items-center gap-1.5">
109109
{AVATAR_PALETTE.map((swatch) => (
110110
<button
111111
key={swatch}
112112
type="button"
113-
aria-label={`Color ${swatch}`}
113+
aria-label={t.bots.dialog.swatch(swatch)}
114114
aria-pressed={color === swatch}
115115
onClick={() => setColor(swatch)}
116116
className={`relative rounded-[3px] p-0.5 ${color === swatch ? "ring-2 ring-accent" : ""}`}
@@ -123,7 +123,7 @@ export function BotDialog(props: Props) {
123123
))}
124124
</div>
125125
{!editing && color === undefined && (
126-
<p className="text-muted text-xs">Left unset, the bot gets the crew's next color.</p>
126+
<p className="text-muted text-xs">{t.bots.dialog.colorUnset}</p>
127127
)}
128128
</fieldset>
129129
{error && (

‎app/src/features/bots/BotHeader.tsx‎

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
RotateCw,
1111
} from "lucide-react";
1212
import { useState } from "react";
13+
import { useT } from "../../i18n";
1314
import type { Bot, Crew } from "../../lib/protocol.gen";
1415
import { useApi, useApp, useHost } from "../../store/context";
1516
import { Button } from "../../ui/Button";
@@ -34,6 +35,8 @@ export function BotHeader({
3435
detailsOpen: boolean;
3536
onToggleDetails(): void;
3637
}) {
38+
const t = useT();
39+
const words = t.bots.header;
3740
const api = useApi();
3841
const host = useHost();
3942
const putBot = useApp((state) => state.putBot);
@@ -42,11 +45,11 @@ export function BotHeader({
4245
const stopped = bot.paused || crew.paused;
4346

4447
const setPaused = (paused: boolean) =>
45-
attempt(paused ? "Could not pause the bot" : "Could not resume the bot", async () =>
48+
attempt(paused ? words.failed.pause : words.failed.resume, async () =>
4649
putBot(await api.call("bots.setPaused", { botId: bot.id, paused })),
4750
);
4851
const restart = (fresh: boolean) =>
49-
attempt("Could not restart the bot", async () =>
52+
attempt(words.failed.restart, async () =>
5053
putBot(await api.call("bots.restart", { botId: bot.id, fresh })),
5154
);
5255

@@ -62,73 +65,71 @@ export function BotHeader({
6265
</div>
6366
<div className="mt-0.5 flex items-center gap-3 text-sm">
6467
<BotStateBadge bot={bot} crewPaused={crew.paused} />
65-
<span className="truncate text-muted">{bot.role || "No role"}</span>
68+
<span className="truncate text-muted">{bot.role || words.noRole}</span>
6669
</div>
6770
</div>
6871
{bot.paused ? (
6972
<Button icon={Play} onClick={() => setPaused(false)}>
70-
Resume
73+
{words.resume}
7174
</Button>
7275
) : (
7376
<Button icon={Pause} onClick={() => setPaused(true)}>
74-
Pause
77+
{words.pause}
7578
</Button>
7679
)}
7780
<Button icon={RotateCw} disabled={stopped} onClick={() => restart(false)}>
78-
Restart
81+
{words.restart}
7982
</Button>
8083
<Button
8184
variant={detailsOpen ? "secondary" : "ghost"}
8285
icon={PanelRight}
83-
label={detailsOpen ? "Hide details" : "Show details"}
86+
label={detailsOpen ? words.hideDetails : words.showDetails}
8487
aria-pressed={detailsOpen}
8588
onClick={onToggleDetails}
8689
/>
8790
<Menu
88-
label="More bot actions"
91+
label={words.more}
8992
icon={Ellipsis}
9093
items={[
91-
{ label: "Edit", icon: Pencil, onSelect: () => setOpen("edit") },
94+
{ label: words.edit, icon: Pencil, onSelect: () => setOpen("edit") },
9295
{
93-
label: "Restart with a new conversation",
96+
label: words.restartFresh,
9497
icon: RefreshCcw,
9598
disabled: stopped,
9699
onSelect: () => setOpen("fresh"),
97100
},
98101
{
99-
label: "Open folder",
102+
label: words.openFolder,
100103
icon: FolderOpen,
101-
onSelect: () =>
102-
attempt("Could not open the folder", () => host.openPath(bot.workspace)),
104+
onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)),
103105
},
104-
{ label: "Archive bot", icon: Archive, danger: true, onSelect: () => setOpen("archive") },
106+
{ label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") },
105107
]}
106108
/>
107109

108110
{open === "edit" && <BotDialog bot={bot} onClose={close} />}
109111
{open === "fresh" && (
110112
<Confirm
111-
title="Start a new conversation?"
112-
confirmLabel="Restart"
113+
title={words.fresh.title}
114+
confirmLabel={words.fresh.confirm}
113115
onClose={close}
114116
onConfirm={() => restart(true)}
115117
>
116-
{bot.name} restarts without its current conversation. Its folder and its CLAUDE.md stay as
117-
they are.
118+
{words.fresh.body(bot.name)}
118119
</Confirm>
119120
)}
120121
{open === "archive" && (
121122
<Confirm
122-
title={`Archive ${bot.name}?`}
123-
confirmLabel="Archive bot"
123+
title={words.archiveConfirm.title(bot.name)}
124+
confirmLabel={words.archiveConfirm.confirm}
124125
onClose={close}
125126
onConfirm={() =>
126-
attempt("Could not archive the bot", async () =>
127+
attempt(words.failed.archive, async () =>
127128
putBot(await api.call("bots.archive", { botId: bot.id })),
128129
)
129130
}
130131
>
131-
The bot stops and leaves the crew. Messages still waiting for it are not delivered.
132+
{words.archiveConfirm.body}
132133
</Confirm>
133134
)}
134135
</header>

‎app/src/features/bots/BotStateBadge.tsx‎

Lines changed: 30 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Power,
1212
RotateCw,
1313
} from "lucide-react";
14+
import { type Messages, t, useT } from "../../i18n";
1415
import type { Bot, BotState } from "../../lib/protocol.gen";
1516

1617
type Tone = "ok" | "work" | "warn" | "danger" | "quiet";
@@ -24,57 +25,38 @@ export interface StateView {
2425
hint: string;
2526
}
2627

27-
const views: Record<BotState, StateView> = {
28-
offline: { label: "Offline", tone: "quiet", icon: Power, hint: "Not running." },
29-
launching: {
30-
label: "Starting",
31-
tone: "work",
32-
icon: LoaderCircle,
33-
spin: true,
34-
hint: "Claude Code is starting.",
35-
},
36-
idle: { label: "Idle", tone: "ok", icon: CircleDot, hint: "Ready for work." },
37-
busy: { label: "Working", tone: "work", icon: Activity, hint: "Working on something." },
38-
needs_approval: {
39-
label: "Needs approval",
40-
tone: "warn",
41-
icon: Hand,
42-
hint: "Waiting for you to allow or deny a tool in its chat.",
43-
},
44-
rate_limited: {
45-
label: "Usage limit",
46-
tone: "warn",
47-
icon: Hourglass,
48-
hint: "Your Claude plan hit its usage limit; messages wait until it resets.",
49-
},
50-
auth_error: {
51-
label: "Sign-in needed",
52-
tone: "danger",
53-
icon: KeyRound,
54-
hint: "Claude Code is not signed in, or the account can't be used. Sign in to Claude and the bot starts again by itself. Already signed in? Check your Claude plan, then restart the bot.",
55-
},
56-
backoff: {
57-
label: "Restarting",
58-
tone: "warn",
59-
icon: RotateCw,
60-
hint: "It stopped unexpectedly; Botloft starts it again shortly.",
61-
},
62-
archived: { label: "Archived", tone: "quiet", icon: Archive, hint: "Archived." },
63-
};
28+
/** How a state looks; its words come from the current language. */
29+
type Look = Omit<StateView, "label" | "hint">;
6430

65-
const paused: StateView = {
66-
label: "Paused",
67-
tone: "quiet",
68-
icon: Pause,
69-
hint: "Paused; resume to start it.",
31+
const looks: Record<BotState, Look> = {
32+
offline: { tone: "quiet", icon: Power },
33+
launching: { tone: "work", icon: LoaderCircle, spin: true },
34+
idle: { tone: "ok", icon: CircleDot },
35+
busy: { tone: "work", icon: Activity },
36+
needs_approval: { tone: "warn", icon: Hand },
37+
rate_limited: { tone: "warn", icon: Hourglass },
38+
auth_error: { tone: "danger", icon: KeyRound },
39+
backoff: { tone: "warn", icon: RotateCw },
40+
archived: { tone: "quiet", icon: Archive },
7041
};
7142

72-
/** How a bot's state reads in the UI. Paused and stopped reads as paused. */
73-
export function stateView(bot: Pick<Bot, "state" | "paused">, crewPaused = false): StateView {
43+
const pausedLook: Look = { tone: "quiet", icon: Pause };
44+
45+
/**
46+
* How a bot's state reads in the UI. Paused and stopped reads as paused.
47+
* Components pass the messages from `useT()` so they follow a language
48+
* change; other callers get the current language.
49+
*/
50+
export function stateView(
51+
bot: Pick<Bot, "state" | "paused">,
52+
crewPaused = false,
53+
messages: Messages = t(),
54+
): StateView {
55+
const words = messages.bots.states;
7456
if ((bot.paused || crewPaused) && bot.state === "offline") {
75-
return paused;
57+
return { ...pausedLook, ...words.paused };
7658
}
77-
return views[bot.state];
59+
return { ...looks[bot.state], ...words[bot.state] };
7860
}
7961

8062
const toneText: Record<Tone, string> = {
@@ -94,7 +76,8 @@ export function BotStateBadge({
9476
crewPaused?: boolean;
9577
compact?: boolean;
9678
}) {
97-
const view = stateView(bot, crewPaused);
79+
const messages = useT();
80+
const view = stateView(bot, crewPaused, messages);
9881
const Icon = view.icon;
9982
return (
10083
<span

0 commit comments

Comments
 (0)