Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff647fba0a | ||
|
|
2f8a2b60d5 | ||
|
|
454a0f4244 | ||
|
|
a48a7ff696 | ||
|
|
bde59eb74e | ||
|
|
0b85d42197 | ||
|
|
2e846933e9 | ||
|
|
5e032898f8 | ||
|
|
91b9bec926 | ||
|
|
b978e09327 | ||
|
|
7f8455f1af | ||
|
|
f6b9ddc877 | ||
|
|
44f5246e38 | ||
|
|
9b0376ec39 | ||
|
|
66234cd3e0 |
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 2026.08.23.04
|
||||
|
||||
The web UI can be read in Portuguese. The shell and the shared components are fully keyed — 581 keys across
|
||||
two locales, with the build failing on a missing one — plus an endpoint for spawning an agent, and spawned
|
||||
agents becoming visible in both directions.
|
||||
|
||||
**No schema change.** Nothing under `src/databases/**/schema.ts` moved across
|
||||
`deploy/2026.08.23.03..HEAD`. `bun db:push` is still a step because it is cheap and it verifies the
|
||||
database agrees with the code, but it should report no changes.
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Localization, phases 0 through 2.** `src/apps/officer-web` and `src/workspaces/components` have no
|
||||
hardcoded user-facing strings left: 555 and 47 occurrences respectively, both now zero. `en-GB` and
|
||||
`pt-PT` are complete and in step at 581 keys.
|
||||
- **Every locale ships a complete catalogue.** There is no fallback between regional variants, so a
|
||||
Portuguese reader is never served Brazilian wording. What enforces that is a **key-parity test that fails
|
||||
the build** on a gap — verified by removing one key and watching it go red.
|
||||
- **The language picker offers only locales that exist.** It listed about twenty codes, eighteen of which
|
||||
silently rendered English. Stored bare codes widen on read (`en` → `en-GB`), with no database migration.
|
||||
- **Dates, numbers and plurals follow the account's locale**, not the browser's, through `useFormat()` and
|
||||
`Intl.PluralRules`. Roughly 23 call sites moved.
|
||||
- **`POST /api/sub-agents/spawn`** — an endpoint for starting an agent from a prompt, for callers that are
|
||||
not a chat and so cannot reach the harness's own tool. Budget-checked before spawning, and it refuses with
|
||||
a 429 naming the window rather than burning the last of one.
|
||||
- **Spawned agents are visible in both directions.** A commander's pane lists its children as links; opening
|
||||
one shows which commander started it, linking back.
|
||||
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
|
||||
error — so answering a question during a restart window vanished and the card spun forever.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.04`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: that means
|
||||
this tree and that host's database disagree about something this release did not touch.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
|
||||
report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
The visible change is the language picker in profile settings. It should offer exactly **English (UK)** and
|
||||
**Português (Portugal)** and nothing else. A host still offering twenty languages did not pick up the
|
||||
frontend bundle.
|
||||
|
||||
No direct SQL is needed and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||
- **`production-pertento-bridge`** — deployed cleanly last release after being two behind. Nothing expected.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The rollout runs from here. It is already at this commit and `officer` has been restarted on it.
|
||||
+46
-25
@@ -25,35 +25,44 @@ the server does instead, which is the one decision that reaches outside this bou
|
||||
|
||||
## What exists today, and what it cannot do
|
||||
|
||||
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
|
||||
also about **5% deployed**, and that gap is the whole job.
|
||||
**Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
|
||||
enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
|
||||
components. All of that is now history and is kept below only as the "before" column, because the shape of
|
||||
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
|
||||
|
||||
| | |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
|
||||
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
|
||||
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
|
||||
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
|
||||
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
||||
| | before phase 0 | now |
|
||||
| -------------------------- | ---------------------- | --------------------------------------------- |
|
||||
| catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
|
||||
| keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
|
||||
| workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
|
||||
| components using it | 3 | all of `officer-web` and `components` |
|
||||
| plural rules | none | `Intl.PluralRules`, per locale |
|
||||
| dates and numbers | browser locale | `useFormat()`, the account's locale |
|
||||
| a missing key | rendered its dot-path | falls back to English, warns in dev |
|
||||
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
|
||||
| parity enforcement | none | **a gap fails the build** |
|
||||
|
||||
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
|
||||
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
|
||||
typecheck failure, which is worth more than it sounds when one agent is editing 400 files.
|
||||
Still standing from the original description, and still true: the package is hand-rolled, has no npm
|
||||
dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
|
||||
sounds when one agent is editing hundreds of files.
|
||||
|
||||
**What it cannot do yet**, each of which is a task below:
|
||||
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
|
||||
wired into the bridge, and empty.
|
||||
|
||||
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
|
||||
runtime and unused.
|
||||
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
|
||||
the _browser's_ locale, not the user's chosen one, so a Portuguese UI shows American dates.
|
||||
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
|
||||
from `pt`.
|
||||
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
|
||||
`officerdev` and the 91 in `components` have no catalog of their own.
|
||||
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the
|
||||
user. No warning, no test.
|
||||
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes
|
||||
including Hebrew and Greek. Choosing any of the eighteen silently yields English.
|
||||
Two things learned doing phases 1 and 2 that the plan did not anticipate:
|
||||
|
||||
- **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
|
||||
did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
|
||||
not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
|
||||
assertions.
|
||||
- **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
|
||||
`useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
|
||||
which phase 2 needed immediately. Memoised.
|
||||
|
||||
**A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
|
||||
key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
|
||||
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
|
||||
raising if a phase turns out to be full of them rather than absorbing it forty times.
|
||||
|
||||
### The size of the remaining work
|
||||
|
||||
@@ -262,6 +271,18 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
|
||||
|
||||
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
||||
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
|
||||
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
|
||||
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
|
||||
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
|
||||
says "Enable".
|
||||
|
||||
So translate the connective prose around them and leave the quoted label in English. Do not key it with
|
||||
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
|
||||
which is a maintenance cost bought for nothing.
|
||||
|
||||
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
|
||||
|
||||
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
|
||||
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
|
||||
names, database values.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.03",
|
||||
"version": "2026.08.23.04",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -38,6 +38,16 @@ const KIND_MEANING: Record<string, string> = {
|
||||
export async function renderPlatformSkill(): Promise<string> {
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||||
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
|
||||
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
|
||||
const enString = (key: string): string => {
|
||||
const value = key.split('.').reduce<unknown>((node, part) => {
|
||||
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, enCatalog);
|
||||
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const byKind = new Map<string, typeof PERMISSIONS>();
|
||||
for (const p of PERMISSIONS) {
|
||||
@@ -112,14 +122,16 @@ export async function renderPlatformSkill(): Promise<string> {
|
||||
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||||
out('list rather than remembering one.');
|
||||
out();
|
||||
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
|
||||
// English, so it resolves against en-GB rather than the reader's locale.
|
||||
for (const section of SHORTCUT_SECTIONS) {
|
||||
out(`## ${section.title}`);
|
||||
if (section.note) {
|
||||
out(`## ${enString(section.titleKey)}`);
|
||||
if (section.noteKey) {
|
||||
out();
|
||||
out(section.note);
|
||||
out(enString(section.noteKey));
|
||||
}
|
||||
out();
|
||||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
|
||||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
|
||||
out();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,12 @@ const AREAS = [
|
||||
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
|
||||
* that must NEVER be translated; they are reported but excluded from the check, which is why the
|
||||
* neverTranslate split below has to exist for this entry to be possible at all.
|
||||
*
|
||||
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
|
||||
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
|
||||
* `stripUntranslated` skips for the same reason.
|
||||
*/
|
||||
const ZEROED: string[] = ['shared components'];
|
||||
const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
|
||||
|
||||
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||
|
||||
@@ -80,11 +84,16 @@ function isHuman(value: string | undefined): value is string {
|
||||
if (!/[A-Za-z]{3}/.test(s)) return false;
|
||||
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
|
||||
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
|
||||
if (/^[A-Z0-9_]+$/.test(s)) return false;
|
||||
// Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
|
||||
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
|
||||
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
|
||||
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
|
||||
// followed by a credential is never something a person reads.
|
||||
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
|
||||
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
|
||||
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
|
||||
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
|
||||
if (!/\s/.test(s) && /\d/.test(s) && /[-_.]/.test(s)) return false;
|
||||
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
|
||||
// `description: 'group-[.toast]:text-muted-foreground'`.
|
||||
//
|
||||
@@ -117,6 +126,18 @@ const stripComments = (source: string): string =>
|
||||
const stripCodeElements = (source: string): string =>
|
||||
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
||||
|
||||
/**
|
||||
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
|
||||
* another product's UI, which docs/localization.md classifies as an identifier.
|
||||
*
|
||||
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
|
||||
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
|
||||
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
|
||||
* it and say why.
|
||||
*/
|
||||
const stripUntranslated = (source: string): string =>
|
||||
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -148,7 +169,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
||||
files++;
|
||||
let source: string;
|
||||
try {
|
||||
source = stripCodeElements(stripComments(readFileSync(file, 'utf8')));
|
||||
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export function ForgotPassword() {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient('/api/auth');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
@@ -34,29 +36,29 @@ export function ForgotPassword() {
|
||||
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
|
||||
{done ? (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">{t('auth.checkEmail')}</h2>
|
||||
<div className="text-sm text-duck-dark/60">
|
||||
<p>
|
||||
A password reset link has been sent to <strong>{state.email}</strong>.
|
||||
</p>
|
||||
<p>Click it to reset your password.</p>
|
||||
<p>{t('auth.checkEmailHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
|
||||
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.forgotTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.forgotSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
@@ -74,7 +76,7 @@ export function ForgotPassword() {
|
||||
<div className="text-center text-sm text-duck-dark/60">
|
||||
<p>
|
||||
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Back to login
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useResetPassword } from './useResetPassword';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export function ResetPassword() {
|
||||
const { t } = useTranslation();
|
||||
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
|
||||
const loading = tokenStatus === 'loading';
|
||||
const invalid = tokenStatus === 'invalid';
|
||||
@@ -15,45 +17,45 @@ export function ResetPassword() {
|
||||
<>
|
||||
{loading && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||
<div className="text-center text-duck-dark/60">{t('auth.verifying')}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invalid && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.invalidLink')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.invalidLinkHint')}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||
<div className="text-duck-dark/60">Enter your new password</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.resetTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.resetSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
placeholder={t('auth.minChars')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
placeholder={t('auth.confirmPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
@@ -65,7 +67,7 @@ export function ResetPassword() {
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
{isSubmitting ? 'Resetting...' : t('auth.resetTitle')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { toast } from 'sonner';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const useResetPassword = () => {
|
||||
const { t } = useTranslation();
|
||||
const isMounted = useMounted();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
@@ -44,7 +46,7 @@ export const useResetPassword = () => {
|
||||
password: state.password,
|
||||
verificationCode,
|
||||
});
|
||||
toast.success('Password reset successfully. Please sign in.');
|
||||
toast.success(t('auth.resetOk'));
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export function Bootstrap() {
|
||||
const { t } = useTranslation();
|
||||
const authClient = useClient('/api/auth');
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -30,7 +32,7 @@ export function Bootstrap() {
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
toast.success('Administrator account created. Please sign in.');
|
||||
toast.success(t('auth.bootstrapOk'));
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
@@ -43,35 +45,35 @@ export function Bootstrap() {
|
||||
return (
|
||||
<Card className="md:min-w-[480px] flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div>
|
||||
<div className="text-duck-dark/60">Create your administrator account</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.bootstrapTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.bootstrapSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<span className="text-duck-dark/70">{t('auth.name')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<span className="text-duck-dark/70">{t('auth.email')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="admin@example.com"
|
||||
placeholder={t('auth.adminEmailPlaceholder')}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Username</span>
|
||||
<span className="text-duck-dark/70">{t('auth.username')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
@@ -83,23 +85,23 @@ export function Bootstrap() {
|
||||
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
placeholder={t('auth.minChars')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
placeholder={t('auth.confirmPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
@@ -8,12 +8,14 @@ import { toast } from 'sonner';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const initialState: LoginFormState = {
|
||||
// email: 'pastilhas@pastilhas.dev',
|
||||
// password: '',
|
||||
};
|
||||
export function Login() {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
||||
const { signin } = useAuth();
|
||||
@@ -40,20 +42,20 @@ export function Login() {
|
||||
return (
|
||||
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
||||
<div className="text-duck-dark text-2xl font-bold">{t('auth.signInTitle')}</div>
|
||||
<div className="text-duck-dark/60">{t('auth.signInSubtitle')}</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email or username</span>
|
||||
<span className="text-duck-dark/70">{t('auth.emailOrUsername')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
|
||||
// field would have validated the user out of an account they have.
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
// `username` rather than `email`, which is what password managers expect for a field that
|
||||
// takes either — and it is the value they already store beside the password.
|
||||
autoComplete="username"
|
||||
@@ -61,12 +63,12 @@ export function Login() {
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<span className="text-duck-dark/70">{t('auth.password')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Label>
|
||||
@@ -90,7 +92,7 @@ export function Login() {
|
||||
{/* </p> */}
|
||||
<p>
|
||||
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
|
||||
Forgot password?
|
||||
{t('auth.forgotPassword')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type DebugPanelProps = {
|
||||
fullControlMode: boolean;
|
||||
cameraInfo: {
|
||||
@@ -13,9 +15,10 @@ type DebugPanelProps = {
|
||||
};
|
||||
|
||||
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
|
||||
<div className="font-bold text-blue-400">Controls:</div>
|
||||
<div className="font-bold text-blue-400">{t('auth.debug.controls')}</div>
|
||||
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
|
||||
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
|
||||
</div>
|
||||
@@ -29,12 +32,12 @@ export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPane
|
||||
<div>* Ctrl+Alt+Enter: Toggle full control</div>
|
||||
<div>* Ctrl+Alt+D: Toggle debug panel</div>
|
||||
|
||||
<div className="font-bold text-green-400 pt-2">Camera:</div>
|
||||
<div className="font-bold text-green-400 pt-2">{t('auth.debug.camera')}</div>
|
||||
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
|
||||
|
||||
<div className="font-bold text-yellow-400 pt-2">Model:</div>
|
||||
<div className="font-bold text-yellow-400 pt-2">{t('auth.debug.model')}</div>
|
||||
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
|
||||
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
|
||||
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
|
||||
@@ -15,6 +16,7 @@ const MAX_LINES = 600;
|
||||
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
|
||||
// the screen re-derives the param from the registry row — one address shape for both kinds.
|
||||
export const ActivityScreen = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, get } = useClient();
|
||||
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
|
||||
const [regLoaded, setRegLoaded] = useState(false);
|
||||
@@ -87,30 +89,32 @@ export const ActivityScreen = () => {
|
||||
<div className="flex h-full w-full">
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
||||
<ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
|
||||
</div>
|
||||
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Background tasks
|
||||
{t('activity.backgroundTasks')}
|
||||
</div>
|
||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
||||
{reg.tasks.map((t) => (
|
||||
{reg.tasks.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
|
||||
)}
|
||||
{reg.tasks.map((task) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/activity/${encodeURIComponent(t.id)}`}
|
||||
className={rowCls(selectedId === t.id)}
|
||||
title={t.cwd}
|
||||
key={task.id}
|
||||
to={`/activity/${encodeURIComponent(task.id)}`}
|
||||
className={rowCls(selectedId === task.id)}
|
||||
title={task.cwd}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
/>
|
||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
||||
<span className="truncate font-mono text-xs">{task.id}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{reg.detached.length > 0 && (
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Detached
|
||||
{t('activity.detached')}
|
||||
</div>
|
||||
)}
|
||||
{reg.detached.map((d) => (
|
||||
@@ -169,7 +173,7 @@ export const ActivityScreen = () => {
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||
it never started.{' '}
|
||||
<Link to="/activity" className="underline">
|
||||
Back to the list
|
||||
{t('activity.backToList')}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
@@ -187,7 +191,7 @@ export const ActivityScreen = () => {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a task to follow its live output
|
||||
{t('activity.selectTask')}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useChat, EmbeddableChat } from 'officerdev';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
type CapabilitySummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
@@ -277,12 +278,18 @@ export const CapabilityChat = ({
|
||||
description,
|
||||
onResponseEnd,
|
||||
}: CapabilityChatProps) => {
|
||||
const { t } = useTranslation();
|
||||
const seedFile = `${kind.toUpperCase()}.md`;
|
||||
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
|
||||
/*
|
||||
The composer's PRE-FILLED text, which the reader sees and edits — so it is translated. The hidden
|
||||
`promptFrontmatter` above is not: that one instructs the agent and carries file paths, and is
|
||||
machine-facing rather than something anybody reads.
|
||||
*/
|
||||
const defaultInput = isNew
|
||||
? (description ?? `Help me create the content for this new ${kind} file`)
|
||||
: `Help me understand and improve this ${kind} file`;
|
||||
? (description ?? t('capability.createPrompt', { kind: kind.toLowerCase() }))
|
||||
: t('capability.improvePrompt', { kind: kind.toLowerCase() });
|
||||
|
||||
const chat = useChat(undefined, undefined, { replaceUrl: false });
|
||||
|
||||
@@ -301,6 +308,7 @@ export const CapabilityChat = ({
|
||||
};
|
||||
|
||||
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -310,7 +318,7 @@ export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<span className="text-xs font-medium">Frontmatter</span>
|
||||
<span className="text-xs font-medium">{t('capability.frontmatter')}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
||||
@@ -330,6 +338,7 @@ export const CapabilityList = ({
|
||||
showCreate,
|
||||
onShowCreateChange,
|
||||
}: CapabilityListProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [internalCreating, setInternalCreating] = useState(false);
|
||||
@@ -356,7 +365,7 @@ export const CapabilityList = ({
|
||||
setNewName('');
|
||||
onCreate(res.dirName);
|
||||
} catch {
|
||||
toast.error(`Failed to create ${kind}`);
|
||||
toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -427,7 +436,7 @@ export const CapabilityList = ({
|
||||
<input
|
||||
value={internalSearch}
|
||||
onChange={(ev) => setInternalSearch(ev.target.value)}
|
||||
placeholder={`Search ${kind.toLowerCase()}s...`}
|
||||
placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
|
||||
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -452,7 +461,7 @@ export const CapabilityList = ({
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
||||
)}
|
||||
{items.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -469,6 +478,7 @@ export const CapabilityList = ({
|
||||
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
|
||||
*/
|
||||
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
@@ -497,7 +507,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
navigate(basePath, { replace: true });
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${kind}`);
|
||||
toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -526,7 +536,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</Link>
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
||||
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
|
||||
</span>
|
||||
{detail && (
|
||||
<>
|
||||
@@ -554,10 +564,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : detail ? (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
|
||||
) : (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
||||
Select a {kind.toLowerCase()} to view its contents
|
||||
{t('capability.selectToView', { kind: kind.toLowerCase() })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -606,10 +616,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||
Delete
|
||||
{t('capability.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||
import { defaultLayout, hasAppType } from './defaultLayout';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
||||
const CHAT_TAIL = 20;
|
||||
@@ -18,6 +19,7 @@ type SessionListPageProps = {
|
||||
};
|
||||
|
||||
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
|
||||
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
||||
const groupCwd = cwdFromSplat(splat);
|
||||
@@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
|
||||
// no hint that the transcript could not be read, which is indistinguishable from a new session.
|
||||
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
|
||||
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
|
||||
toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
|
||||
setSelected({ id: sessionId });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { CodeEditorView } from 'officerdev';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const CodeEditor = () => (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
||||
export const CodeEditor = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Widget title={t('nav.codeEditor')} className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
||||
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
|
||||
<CodeEditorView className="h-full w-full" urlState />
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
<CodeEditorView className="h-full w-full" urlState />
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useParams, Link } from 'react-router';
|
||||
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type TFn = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
|
||||
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
|
||||
@@ -29,26 +32,40 @@ type DownloadJob = {
|
||||
const POLL_MS = 1500;
|
||||
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
// Takes `t` because it is a module-level helper, not a component — the alternative is a hook
|
||||
// call outside a component body.
|
||||
const statusBadge = (status: string, t: TFn) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
label: 'Running',
|
||||
label: t('jobs.status.running'),
|
||||
cls: 'text-amber-600 dark:text-amber-500',
|
||||
};
|
||||
case 'pending':
|
||||
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
label: t('jobs.status.queued'),
|
||||
cls: 'text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
case 'completed':
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
|
||||
case 'failed':
|
||||
return { icon: <XCircle className="h-4 w-4" />, label: 'Failed', cls: 'text-red-600 dark:text-red-400' };
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: t('jobs.status.failed'),
|
||||
cls: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
case 'stopped':
|
||||
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
|
||||
return {
|
||||
icon: <Ban className="h-4 w-4" />,
|
||||
label: t('jobs.status.stopped'),
|
||||
cls: 'text-duck-dark/50 dark:text-foreground/50',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: 'Interrupted',
|
||||
label: t('jobs.status.interrupted'),
|
||||
cls: 'text-orange-600 dark:text-orange-400',
|
||||
};
|
||||
}
|
||||
@@ -100,6 +117,7 @@ export const PhaseBar = ({
|
||||
};
|
||||
|
||||
export const DownloadJobDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
const [job, setJob] = useState<DownloadJob | null>(null);
|
||||
@@ -134,9 +152,9 @@ export const DownloadJobDetail = () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await client.post(`/jobs/${id}/stop`, {});
|
||||
toast.success('Stop requested');
|
||||
toast.success(t('jobs.stopRequested'));
|
||||
} catch {
|
||||
toast.error('Failed to stop');
|
||||
toast.error(t('jobs.stopFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,14 +167,14 @@ export const DownloadJobDetail = () => {
|
||||
if (!job)
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
||||
<span>Job not found.</span>
|
||||
<span>{t('jobs.notFound')}</span>
|
||||
<Link to="/jobs" className="text-duck-teal hover:underline">
|
||||
Back to jobs
|
||||
{t('jobs.backToJobs')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const badge = statusBadge(job.status);
|
||||
const badge = statusBadge(job.status, t);
|
||||
const running = !isTerminal(job.status);
|
||||
const p = job.progress;
|
||||
const isAudio = job.inputs?.format !== 'video';
|
||||
@@ -197,7 +215,7 @@ export const DownloadJobDetail = () => {
|
||||
onClick={stop}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
||||
>
|
||||
<Square className="h-3 w-3" /> Stop
|
||||
<Square className="h-3 w-3" /> {t('jobs.stop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -206,14 +224,14 @@ export const DownloadJobDetail = () => {
|
||||
<div className="mx-auto flex max-w-md flex-col gap-5">
|
||||
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
|
||||
<PhaseBar
|
||||
label="Titles"
|
||||
label={t('jobs.titles')}
|
||||
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
|
||||
active={p?.phase === 'metadata'}
|
||||
savedLabel="found"
|
||||
failedLabel="skipped"
|
||||
/>
|
||||
<PhaseBar
|
||||
label="Download"
|
||||
label={t('jobs.download')}
|
||||
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
|
||||
active={p?.phase === 'download'}
|
||||
savedLabel="saved"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
|
||||
@@ -162,6 +163,7 @@ type ToolCallEntryProps = {
|
||||
};
|
||||
|
||||
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -183,14 +185,14 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
||||
{expanded && (
|
||||
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
|
||||
<div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.input')}</div>
|
||||
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
|
||||
{JSON.stringify(entry.toolInput, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{entry.output !== undefined && (
|
||||
<div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
|
||||
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.output')}</div>
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||
>
|
||||
@@ -236,6 +238,7 @@ const DEFAULT_LAYOUT: LayoutNode = {
|
||||
};
|
||||
|
||||
const StepsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useJobPanel();
|
||||
const {
|
||||
displaySteps,
|
||||
@@ -255,7 +258,7 @@ const StepsPanel = () => {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-duck-dark/10">
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2>
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.steps')}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{displaySteps.map((step, i) => {
|
||||
@@ -336,6 +339,7 @@ const StepsPanel = () => {
|
||||
};
|
||||
|
||||
const OutputPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useJobPanel();
|
||||
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
|
||||
|
||||
@@ -345,7 +349,7 @@ const OutputPanel = () => {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
|
||||
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.output')}</h2>
|
||||
{selectedKey && (
|
||||
<span className="text-xs text-duck-dark/40 truncate">
|
||||
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
|
||||
@@ -355,12 +359,12 @@ const OutputPanel = () => {
|
||||
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
||||
{!selectedKey && (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
||||
Select a step or sub-task to view output
|
||||
{t('jobs.selectStep')}
|
||||
</div>
|
||||
)}
|
||||
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
||||
No output yet
|
||||
{t('jobs.noOutput')}
|
||||
</div>
|
||||
)}
|
||||
{selectedOutputs.map((entry) => {
|
||||
@@ -385,6 +389,7 @@ const OutputPanel = () => {
|
||||
};
|
||||
|
||||
export const PipelineJobDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
|
||||
@@ -792,15 +797,15 @@ export const PipelineJobDetail = () => {
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
|
||||
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">{t('jobs.loading')}</div>;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
|
||||
<span>Job not found</span>
|
||||
<span>{t('jobs.notFound')}</span>
|
||||
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
|
||||
Back to jobs
|
||||
{t('jobs.backToJobs')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -821,7 +826,11 @@ export const PipelineJobDetail = () => {
|
||||
<Card className="px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
|
||||
<Link to="/jobs" aria-label="Back to jobs" className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
||||
<Link
|
||||
to="/jobs"
|
||||
aria-label={t('jobs.backToJobs')}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -837,7 +846,7 @@ export const PipelineJobDetail = () => {
|
||||
{isRunning && (
|
||||
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
|
||||
<Square className="h-3 w-3" />
|
||||
Stop
|
||||
{t('jobs.stop')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -852,13 +861,13 @@ export const PipelineJobDetail = () => {
|
||||
{displayCost && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
||||
<span className="text-duck-dark/40">Tokens:</span>
|
||||
<span className="text-duck-dark/40">{t('jobs.tokens')}</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
||||
<span className="text-duck-dark/40">Cost:</span>
|
||||
<span className="text-duck-dark/40">{t('jobs.cost')}</span>
|
||||
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Card } from '@/components/Card';
|
||||
import { ScriptJobDetail } from './ScriptJobDetail';
|
||||
import { DownloadJobDetail } from './DownloadJobDetail';
|
||||
import { PipelineJobDetail } from './JobDetail';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type JobSummary = {
|
||||
id: string;
|
||||
@@ -99,6 +100,7 @@ const useJobsData = () => {
|
||||
|
||||
type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void };
|
||||
const JobRow = ({ job, onAction }: JobRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { id: activeId } = useParams<{ id: string }>();
|
||||
const isRunning = job.status === 'running';
|
||||
const target = basename(job.target);
|
||||
@@ -126,7 +128,7 @@ const JobRow = ({ job, onAction }: JobRowProps) => {
|
||||
</Link>
|
||||
<button
|
||||
onClick={(ev) => onAction(ev, job)}
|
||||
title={isRunning ? 'Stop' : 'Delete'}
|
||||
title={isRunning ? t('jobs.stop') : 'Delete'}
|
||||
className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
{isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
|
||||
@@ -144,6 +146,7 @@ const PanelHeader = ({ children }: { children: ReactNode }) => (
|
||||
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
|
||||
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
|
||||
const TestNotificationButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
@@ -157,12 +160,12 @@ const TestNotificationButton = () => {
|
||||
);
|
||||
const results = res?.results ?? [];
|
||||
if (results.length === 0) {
|
||||
toast.warning('No channels configured');
|
||||
toast.warning(t('jobs.noChannels'));
|
||||
} else {
|
||||
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to send notification');
|
||||
toast.error(t('jobs.notifyFailed'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -172,7 +175,7 @@ const TestNotificationButton = () => {
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={sending}
|
||||
title="Send a test notification to every configured channel"
|
||||
title={t('jobs.testNotification')}
|
||||
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
@@ -182,6 +185,7 @@ const TestNotificationButton = () => {
|
||||
};
|
||||
|
||||
const ActiveJobsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { jobs, isLoading, act } = useJobsData();
|
||||
const active = [
|
||||
...jobs.filter((j) => j.status === 'running'),
|
||||
@@ -191,15 +195,17 @@ const ActiveJobsPanel = () => {
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex flex-col overflow-hidden">
|
||||
<PanelHeader>
|
||||
<h2 className="text-sm font-semibold text-duck-dark">Running & Queued</h2>
|
||||
<h2 className="text-sm font-semibold text-duck-dark">{t('jobs.runningQueued')}</h2>
|
||||
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
|
||||
<TestNotificationButton />
|
||||
</PanelHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
|
||||
) : active.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Nothing running</div>
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
|
||||
{t('jobs.nothingRunning')}
|
||||
</div>
|
||||
) : (
|
||||
active.map((job) => <JobRow key={job.id} job={job} onAction={act} />)
|
||||
)}
|
||||
@@ -211,6 +217,7 @@ const ActiveJobsPanel = () => {
|
||||
|
||||
// Bottom-left panel: finished / failed / stopped, newest first, searchable.
|
||||
const HistoryJobsPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { jobs, isLoading, act, clearHistory } = useJobsData();
|
||||
const [search, setSearch] = useState('');
|
||||
const history = jobs
|
||||
@@ -224,12 +231,12 @@ const HistoryJobsPanel = () => {
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex flex-col overflow-hidden">
|
||||
<PanelHeader>
|
||||
<h2 className="text-sm font-semibold text-duck-dark shrink-0">History</h2>
|
||||
<h2 className="text-sm font-semibold text-duck-dark shrink-0">{t('jobs.history')}</h2>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
placeholder={t('jobs.search')}
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||
@@ -238,16 +245,16 @@ const HistoryJobsPanel = () => {
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
title="Delete all finished jobs"
|
||||
title={t('jobs.deleteFinished')}
|
||||
className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
{t('jobs.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
|
||||
{search ? 'No matches' : 'No finished jobs'}
|
||||
@@ -263,6 +270,7 @@ const HistoryJobsPanel = () => {
|
||||
|
||||
// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ──
|
||||
const JobDetailPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
const [mode, setMode] = useState<string | null>(null);
|
||||
@@ -284,7 +292,7 @@ const JobDetailPanel = () => {
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30">
|
||||
<Inbox className="h-8 w-8" />
|
||||
<span className="text-sm">Select a job</span>
|
||||
<span className="text-sm">{t('jobs.selectJob')}</span>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -301,7 +309,7 @@ const JobDetailPanel = () => {
|
||||
if (mode === 'notfound') {
|
||||
return (
|
||||
<div className="h-full p-2">
|
||||
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">Job not found.</Card>
|
||||
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">{t('jobs.notFound')}</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'l
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type TFn = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
type ScriptJob = {
|
||||
id: string;
|
||||
@@ -27,36 +30,46 @@ const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === '
|
||||
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
|
||||
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
|
||||
|
||||
const statusBadge = (status: string, exitCode: number | null) => {
|
||||
// Takes `t` because it is a module-level helper, not a component.
|
||||
const statusBadge = (status: string, exitCode: number | null, t: TFn) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
label: 'Running',
|
||||
label: t('jobs.status.running'),
|
||||
cls: 'text-amber-600 dark:text-amber-500',
|
||||
};
|
||||
case 'pending':
|
||||
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
label: t('jobs.status.queued'),
|
||||
cls: 'text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
case 'completed':
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
|
||||
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
|
||||
case 'failed':
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`,
|
||||
label: exitCode != null ? t('jobs.failedWithCode', { code: exitCode }) : t('jobs.status.failed'),
|
||||
cls: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
case 'stopped':
|
||||
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
|
||||
return {
|
||||
icon: <Ban className="h-4 w-4" />,
|
||||
label: t('jobs.status.stopped'),
|
||||
cls: 'text-duck-dark/50 dark:text-foreground/50',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
label: 'Interrupted',
|
||||
label: t('jobs.status.interrupted'),
|
||||
cls: 'text-orange-600 dark:text-orange-400',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ScriptJobDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const client = useClient();
|
||||
|
||||
@@ -137,9 +150,9 @@ export const ScriptJobDetail = () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await client.post(`/jobs/${id}/stop`, {});
|
||||
toast.success('Stop requested');
|
||||
toast.success(t('jobs.stopRequested'));
|
||||
} catch {
|
||||
toast.error('Failed to stop');
|
||||
toast.error(t('jobs.stopFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,15 +166,15 @@ export const ScriptJobDetail = () => {
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
|
||||
<span>Job not found.</span>
|
||||
<span>{t('jobs.notFound')}</span>
|
||||
<Link to="/jobs" className="text-duck-teal hover:underline">
|
||||
Back to jobs
|
||||
{t('jobs.backToJobs')}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusBadge(job.status, job.exitCode);
|
||||
const badge = statusBadge(job.status, job.exitCode, t);
|
||||
const running = !isTerminal(job.status);
|
||||
|
||||
return (
|
||||
@@ -185,7 +198,7 @@ export const ScriptJobDetail = () => {
|
||||
onClick={stop}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
|
||||
>
|
||||
<Square className="h-3 w-3" /> Stop
|
||||
<Square className="h-3 w-3" /> {t('jobs.stop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -193,14 +206,14 @@ export const ScriptJobDetail = () => {
|
||||
{isDownloadProgress(job.progress) && (
|
||||
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
|
||||
<PhaseBar
|
||||
label="Titles"
|
||||
label={t('jobs.titles')}
|
||||
c={job.progress.meta}
|
||||
active={job.progress.phase === 'metadata'}
|
||||
savedLabel="found"
|
||||
failedLabel="skipped"
|
||||
/>
|
||||
<PhaseBar
|
||||
label="Download"
|
||||
label={t('jobs.download')}
|
||||
c={job.progress.dl}
|
||||
active={job.progress.phase === 'download'}
|
||||
savedLabel="saved"
|
||||
|
||||
@@ -66,12 +66,16 @@ export const ShortcutsModal = () => {
|
||||
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
||||
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
||||
{SHORTCUT_SECTIONS.map((section) => (
|
||||
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
|
||||
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
|
||||
<section key={section.titleKey} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">
|
||||
{t(section.titleKey)}
|
||||
</h3>
|
||||
{section.noteKey && (
|
||||
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{t(section.noteKey)}</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
|
||||
<Row key={shortcut.keys} keys={shortcut.keys} description={t(shortcut.descriptionKey)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { KeyRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const PasskeyGate = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, createPasskeyCredentials } = useAuth();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
@@ -31,7 +33,7 @@ export const PasskeyGate = () => {
|
||||
<KeyRound className="h-8 w-8 text-duck-teal" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Set up your passkey</h2>
|
||||
<h2 className="text-2xl font-bold text-duck-dark">{t('passkeyGate.setUp')}</h2>
|
||||
|
||||
<p className="text-duck-dark/70">
|
||||
Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own
|
||||
|
||||
+5
-3
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type ApifyConfigData = {
|
||||
apiToken: string;
|
||||
@@ -15,6 +16,7 @@ type ApifyStatus = {
|
||||
};
|
||||
|
||||
export const ApifyConfig = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -38,10 +40,10 @@ export const ApifyConfig = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await client.put('/integrations/apify/config', { apiToken: apiToken.trim() });
|
||||
toast.success('Apify API token saved');
|
||||
toast.success(t('settings.apify.saved'));
|
||||
setStatus({ configured: !!apiToken.trim() });
|
||||
} catch {
|
||||
toast.error('Failed to save Apify API token');
|
||||
toast.error(t('settings.apify.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -64,7 +66,7 @@ export const ApifyConfig = () => {
|
||||
|
||||
<form onSubmit={handleSave} className="grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">API Token</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.apify.token')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
|
||||
+34
-23
@@ -7,6 +7,8 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useFormat } from 'i18n';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
||||
//
|
||||
@@ -26,19 +28,21 @@ type DavPassword = {
|
||||
|
||||
type Minted = { password: string; username: string; label: string };
|
||||
|
||||
const formatDate = (value: string | null) =>
|
||||
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
||||
|
||||
const copy = async (text: string, what: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast.success(`${what} copied`);
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
}
|
||||
};
|
||||
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
|
||||
|
||||
export const DavAppPasswords = () => {
|
||||
const { t } = useTranslation();
|
||||
const { date: formatDate } = useFormat();
|
||||
|
||||
const copy = async (text: string, what: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast.success(t('settings.dav.copied', { what }));
|
||||
} catch {
|
||||
toast.error(t('settings.dav.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const [passwords, setPasswords] = useState<DavPassword[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -71,7 +75,7 @@ export const DavAppPasswords = () => {
|
||||
setLabel('');
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Could not create the app password');
|
||||
toast.error(t('settings.dav.createFailed'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -80,10 +84,10 @@ export const DavAppPasswords = () => {
|
||||
const revoke = async (id: number) => {
|
||||
try {
|
||||
await client.post(`/dav/passwords/${id}/revoke`, {});
|
||||
toast.success('Revoked — that device will stop syncing');
|
||||
toast.success(t('settings.dav.revoked'));
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Could not revoke');
|
||||
toast.error(t('settings.dav.revokeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +96,7 @@ export const DavAppPasswords = () => {
|
||||
await client.delete(`/dav/passwords/${id}`);
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Could not delete');
|
||||
toast.error(t('settings.dav.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,7 +130,12 @@ export const DavAppPasswords = () => {
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copy(value, field)} title={`Copy ${field}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copy(value, field)}
|
||||
title={t('settings.dav.copyField', { field })}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -146,29 +155,31 @@ export const DavAppPasswords = () => {
|
||||
|
||||
<form onSubmit={create} className="flex items-end gap-2">
|
||||
<Label className="grid flex-1 gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20"
|
||||
value={label}
|
||||
onChange={(ev) => setLabel(ev.target.value)}
|
||||
placeholder="Pixel 9, iPad, Thunderbird…"
|
||||
placeholder={t('settings.dav.placeholder')}
|
||||
/>
|
||||
</Label>
|
||||
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Create
|
||||
{t('settings.dav.create')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passwords.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No devices yet.
|
||||
{t('settings.dav.none')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{passwords.map((entry) => {
|
||||
const revoked = !!entry.revokedAt;
|
||||
const lastUsed = formatDate(entry.lastUsedAt);
|
||||
const lastUsed = entry.lastUsedAt
|
||||
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
@@ -192,7 +203,7 @@ export const DavAppPasswords = () => {
|
||||
</div>
|
||||
</div>
|
||||
{!revoked && (
|
||||
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title="Revoke">
|
||||
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -200,7 +211,7 @@ export const DavAppPasswords = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(entry.id)}
|
||||
title="Delete"
|
||||
title={t('settings.dav.delete')}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
+13
-11
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type GoogleStatus = {
|
||||
connected: boolean;
|
||||
@@ -11,6 +12,7 @@ type GoogleStatus = {
|
||||
};
|
||||
|
||||
export const GoogleAccount = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [status, setStatus] = useState<GoogleStatus>({
|
||||
@@ -30,9 +32,9 @@ export const GoogleAccount = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result = params.get('google');
|
||||
if (result === 'success') {
|
||||
toast.success('Google account connected');
|
||||
toast.success(t('settings.googleAccount.connectedToast'));
|
||||
} else if (result === 'error') {
|
||||
toast.error('Failed to connect Google account');
|
||||
toast.error(t('settings.googleAccount.connectFailed'));
|
||||
}
|
||||
if (result) {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
@@ -51,9 +53,9 @@ export const GoogleAccount = () => {
|
||||
try {
|
||||
await client.delete('/integrations/google/connection');
|
||||
setStatus({ ...status, connected: false, email: null });
|
||||
toast.success('Google account disconnected');
|
||||
toast.success(t('settings.googleAccount.disconnectedToast'));
|
||||
} catch {
|
||||
toast.error('Failed to disconnect Google account');
|
||||
toast.error(t('settings.googleAccount.disconnectFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,17 +65,17 @@ export const GoogleAccount = () => {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||
Connect your Google account for Calendar, Contacts, and other Google services.
|
||||
</p>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">{t('settings.googleAccount.title')}</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">{t('settings.googleAccount.hint')}</p>
|
||||
</div>
|
||||
{status.connected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{t('settings.googleAccount.connected')}
|
||||
</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
||||
</div>
|
||||
{status.picture && (
|
||||
@@ -81,7 +83,7 @@ export const GoogleAccount = () => {
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
||||
Disconnect
|
||||
{t('settings.googleAccount.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -90,7 +92,7 @@ export const GoogleAccount = () => {
|
||||
onClick={handleConnect}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
Connect Google Account
|
||||
{t('settings.googleAccount.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+89
-111
@@ -7,197 +7,175 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { Untranslated } from '@/lib/Untranslated';
|
||||
|
||||
type GoogleOAuthSettings = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
// The scope strings are Google's own identifiers; only the explanation beside them is ours.
|
||||
const SCOPES = [
|
||||
{ scope: 'gmail.readonly', description: 'Read emails' },
|
||||
{ scope: 'calendar.readonly', description: 'Read calendar events' },
|
||||
];
|
||||
{ scope: 'gmail.readonly', descriptionKey: 'settings.google.scopeGmail' },
|
||||
{ scope: 'calendar.readonly', descriptionKey: 'settings.google.scopeCalendar' },
|
||||
] as const;
|
||||
|
||||
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
/*
|
||||
Every quoted Google-console label below is wrapped in <Untranslated>. Per docs/localization.md they
|
||||
are identifiers, not prose: they exist to match what the reader sees on Google's screen, and Google
|
||||
localises by the reader's Google account language rather than ours.
|
||||
|
||||
The sentences are keyed WHOLE, each ending at the label or link it introduces. The first attempt
|
||||
split them around inline labels, which needs "Go to the" and "page." as separate keys — and the
|
||||
Portuguese value of the second one is "." That is the fragmentation smell, so the English was
|
||||
rephrased to put the label at a clause boundary, which reads naturally in both languages.
|
||||
*/
|
||||
const GoogleLink = ({ href, label }: { href: string; label: string }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<Untranslated reason="foreign-ui-label">{label}</Untranslated>
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||
Step-by-step setup guide
|
||||
{t('settings.google.guideToggle')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
|
||||
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step1.title')}</strong>
|
||||
<p className="mt-1">
|
||||
Go to the{' '}
|
||||
<a
|
||||
{t('settings.google.step1.body')}{' '}
|
||||
<GoogleLink
|
||||
href="https://console.cloud.google.com/projectcreate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
New Project
|
||||
</a>{' '}
|
||||
page. Give it a name (e.g. "Officer") and click <strong>Create</strong>.
|
||||
label={t('settings.google.step1.link')}
|
||||
/>{' '}
|
||||
→ <Untranslated reason="foreign-ui-label">Create</Untranslated>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
|
||||
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step2.title')}</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a
|
||||
{t('settings.google.step2.body')}{' '}
|
||||
<GoogleLink
|
||||
href="https://console.cloud.google.com/apis/library"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
API Library
|
||||
</a>
|
||||
. Search for and enable each of these:
|
||||
label={t('settings.google.step2.link')}
|
||||
/>{' '}
|
||||
→ <Untranslated reason="foreign-ui-label">Enable</Untranslated>
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>
|
||||
<strong>Gmail API</strong>
|
||||
<Untranslated reason="foreign-ui-label">Gmail API</Untranslated>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Google Calendar API</strong>
|
||||
<Untranslated reason="foreign-ui-label">Google Calendar API</Untranslated>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1">
|
||||
Click each one, then click <strong>Enable</strong>.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
|
||||
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step3.title')}</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a
|
||||
<GoogleLink
|
||||
href="https://console.cloud.google.com/auth/branding"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
OAuth Branding
|
||||
</a>
|
||||
.
|
||||
label={t('settings.google.step3.link')}
|
||||
/>
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>
|
||||
Set <strong>App name</strong> to your organization name or "Officer"
|
||||
{t('settings.google.step3.appName')} <Untranslated reason="foreign-ui-label">App name</Untranslated>
|
||||
</li>
|
||||
<li>
|
||||
Set <strong>User support email</strong> to your admin email
|
||||
{t('settings.google.step3.supportEmail')}{' '}
|
||||
<Untranslated reason="foreign-ui-label">User support email</Untranslated>
|
||||
</li>
|
||||
<li>
|
||||
Add your admin email under <strong>Developer contact information</strong>
|
||||
{t('settings.google.step3.devContact')}{' '}
|
||||
<Untranslated reason="foreign-ui-label">Developer contact information</Untranslated>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Save</strong>
|
||||
{t('settings.google.step3.save')} <Untranslated reason="foreign-ui-label">Save</Untranslated>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
|
||||
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step4.title')}</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a
|
||||
<GoogleLink
|
||||
href="https://console.cloud.google.com/auth/audience"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
OAuth Audience
|
||||
</a>
|
||||
.
|
||||
label={t('settings.google.step4.link')}
|
||||
/>
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>
|
||||
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
|
||||
{t('settings.google.step4.internal')} <Untranslated reason="foreign-ui-label">Internal</Untranslated>
|
||||
</li>
|
||||
<li>
|
||||
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
|
||||
(required while the app is unverified; limit of 100 test users)
|
||||
{t('settings.google.step4.external')} <Untranslated reason="foreign-ui-label">External</Untranslated>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
|
||||
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step5.title')}</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/scopes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
Data Access
|
||||
</a>
|
||||
, then click <strong>Add or remove scopes</strong>. Search for and add:
|
||||
{t('settings.google.step5.body')}{' '}
|
||||
<GoogleLink href="https://console.cloud.google.com/auth/scopes" label={t('settings.google.step5.link')} />{' '}
|
||||
→ <Untranslated reason="foreign-ui-label">Add or remove scopes</Untranslated>
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
{SCOPES.map((s) => (
|
||||
<li key={s.scope}>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
|
||||
{s.description}
|
||||
{SCOPES.map((entry) => (
|
||||
<li key={entry.scope}>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
|
||||
{entry.scope}
|
||||
</code>{' '}
|
||||
— {t(entry.descriptionKey)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1">
|
||||
Click <strong>Update</strong>, then <strong>Save</strong>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
|
||||
is classified as <strong>sensitive</strong> and{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
|
||||
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
|
||||
in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">{t('settings.google.step5.note')}</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
|
||||
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step6.title')}</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a
|
||||
{t('settings.google.step6.body')}{' '}
|
||||
<GoogleLink
|
||||
href="https://console.cloud.google.com/auth/clients"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
Clients
|
||||
</a>
|
||||
, then click <strong>Create OAuth client</strong>.
|
||||
label={t('settings.google.step6.link')}
|
||||
/>{' '}
|
||||
→ <Untranslated reason="foreign-ui-label">Create OAuth client</Untranslated>
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>
|
||||
Application type: <strong>Web application</strong>
|
||||
{t('settings.google.step6.appType')}{' '}
|
||||
<Untranslated reason="foreign-ui-label">Web application</Untranslated>
|
||||
</li>
|
||||
<li>Name: anything (e.g. "Officer")</li>
|
||||
<li>{t('settings.google.step6.nameAnything')}</li>
|
||||
<li>
|
||||
Authorized redirect URIs: add{' '}
|
||||
{t('settings.google.step6.redirectUris')}{' '}
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
|
||||
{redirectUri}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
{t('settings.google.step6.thenClick')} <Untranslated reason="foreign-ui-label">Create</Untranslated>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
|
||||
<p className="mt-1">
|
||||
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
|
||||
them into the fields below.
|
||||
</p>
|
||||
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step7.title')}</strong>
|
||||
<p className="mt-1">{t('settings.google.step7.body')}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</CollapsibleContent>
|
||||
@@ -208,11 +186,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
type VerifyStatus = { valid: boolean; error: string | null } | null;
|
||||
|
||||
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
if (isVerifying) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" />
|
||||
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">Verifying credentials...</span>
|
||||
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">{t('settings.google.verifying')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -223,13 +202,14 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-success' : 'bg-red-500'}`} />
|
||||
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
|
||||
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
|
||||
{status.valid ? t('settings.google.valid') : (status.error ?? t('settings.google.invalid'))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GoogleOAuthConfig = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -243,7 +223,7 @@ export const GoogleOAuthConfig = () => {
|
||||
client
|
||||
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
|
||||
.then(setVerifyStatus)
|
||||
.catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' }))
|
||||
.catch(() => setVerifyStatus({ valid: false, error: t('settings.google.verifyFailed') }))
|
||||
.finally(() => setIsVerifying(false));
|
||||
};
|
||||
|
||||
@@ -273,10 +253,10 @@ export const GoogleOAuthConfig = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
|
||||
toast.success('Google OAuth configuration saved');
|
||||
toast.success(t('settings.google.saved'));
|
||||
verify();
|
||||
} catch {
|
||||
toast.error('Failed to save Google OAuth configuration');
|
||||
toast.error(t('settings.google.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -294,7 +274,7 @@ export const GoogleOAuthConfig = () => {
|
||||
|
||||
<form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientId')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
@@ -305,7 +285,7 @@ export const GoogleOAuthConfig = () => {
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Client Secret</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientSecret')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
@@ -316,10 +296,8 @@ export const GoogleOAuthConfig = () => {
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Redirect URI</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Add this URI to your Google OAuth client's authorized redirect URIs
|
||||
</p>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.redirectUri')}</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.google.redirectUriHint')}</p>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
|
||||
type="text"
|
||||
|
||||
+44
-32
@@ -13,6 +13,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
|
||||
type StoredApiKeys = { keys: { provider: string; value: string }[] };
|
||||
@@ -38,6 +39,7 @@ type ChatProvider = { key: string; providerId: string };
|
||||
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
|
||||
|
||||
export const AIHarnessesSection = () => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [installing, setInstalling] = useState(false);
|
||||
@@ -168,7 +170,7 @@ export const AIHarnessesSection = () => {
|
||||
// No auth needed — save directly
|
||||
await saveLocalProvider(result, auth);
|
||||
} catch {
|
||||
toast.error('Failed to connect');
|
||||
toast.error(t('settings.server.harnesses.connectFailed'));
|
||||
setProbeState({ step: 'url' });
|
||||
}
|
||||
};
|
||||
@@ -192,13 +194,13 @@ export const AIHarnessesSection = () => {
|
||||
return;
|
||||
}
|
||||
if (result.needsAuth) {
|
||||
toast.error('Authentication failed');
|
||||
toast.error(t('settings.server.harnesses.authFailed'));
|
||||
setProbeState({ step: 'auth', probe });
|
||||
return;
|
||||
}
|
||||
await saveLocalProvider(result, auth);
|
||||
} catch {
|
||||
toast.error('Failed to connect');
|
||||
toast.error(t('settings.server.harnesses.connectFailed'));
|
||||
setProbeState({ step: 'auth', probe });
|
||||
}
|
||||
};
|
||||
@@ -218,10 +220,10 @@ export const AIHarnessesSection = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_HEALTH'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
|
||||
toast.success(`Connected to ${probe.name}`);
|
||||
toast.success(t('settings.server.harnesses.connectedTo', { name: probe.name ?? '' }));
|
||||
resetLocalForm();
|
||||
} catch {
|
||||
toast.error('Failed to save provider');
|
||||
toast.error(t('settings.server.harnesses.saveFailed'));
|
||||
setProbeState({ step: 'url' });
|
||||
}
|
||||
};
|
||||
@@ -254,9 +256,9 @@ export const AIHarnessesSection = () => {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-duck-dark">Pi Mono</span>
|
||||
<span className="text-sm font-medium text-duck-dark">{t('settings.server.harnesses.piMono')}</span>
|
||||
{piMonoLoading ? (
|
||||
<span className="text-xs text-duck-dark/50">Checking...</span>
|
||||
<span className="text-xs text-duck-dark/50">{t('settings.server.harnesses.checking')}</span>
|
||||
) : piMonoVersion?.version ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span>
|
||||
@@ -265,7 +267,7 @@ export const AIHarnessesSection = () => {
|
||||
onClick={installAgent}
|
||||
disabled={installing}
|
||||
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
|
||||
title="Update Pi Mono"
|
||||
title={t('settings.server.harnesses.updatePiMono')}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
@@ -285,13 +287,21 @@ export const AIHarnessesSection = () => {
|
||||
<>
|
||||
{/* Local Providers */}
|
||||
<div className="mt-6 text-xs text-duck-dark/50">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{t('settings.server.harnesses.localProviders')}
|
||||
</span>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{localProviders.map((lp: LocalProviderEntry) => (
|
||||
<div key={lp.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-success' : 'bg-red-500') : 'bg-duck-dark/20'}`}
|
||||
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
|
||||
title={
|
||||
lp.id in localHealth
|
||||
? localHealth[lp.id]
|
||||
? 'Online'
|
||||
: 'Offline'
|
||||
: t('settings.server.harnesses.checking')
|
||||
}
|
||||
/>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
|
||||
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
|
||||
@@ -299,7 +309,7 @@ export const AIHarnessesSection = () => {
|
||||
type="button"
|
||||
onClick={() => removeLocalProvider(lp.id)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Remove provider"
|
||||
title={t('settings.server.harnesses.removeProvider')}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
||||
</button>
|
||||
@@ -312,7 +322,7 @@ export const AIHarnessesSection = () => {
|
||||
<Input
|
||||
type="text"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Name (e.g. My Ollama)"
|
||||
placeholder={t('settings.server.harnesses.namePlaceholder')}
|
||||
value={localName}
|
||||
onChange={(ev) => setLocalName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
@@ -340,7 +350,7 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!localUrl.trim()}
|
||||
>
|
||||
Connect
|
||||
{t('settings.server.harnesses.connect')}
|
||||
</Button>
|
||||
)}
|
||||
{(probeState.step === 'probing' || probeState.step === 'saving') && (
|
||||
@@ -351,7 +361,7 @@ export const AIHarnessesSection = () => {
|
||||
onClick={resetLocalForm}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -370,7 +380,7 @@ export const AIHarnessesSection = () => {
|
||||
<Input
|
||||
type="text"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Username"
|
||||
placeholder={t('settings.server.harnesses.username')}
|
||||
value={authUsername}
|
||||
onChange={(ev) => setAuthUsername(ev.target.value)}
|
||||
autoFocus
|
||||
@@ -378,7 +388,7 @@ export const AIHarnessesSection = () => {
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
placeholder="Password"
|
||||
placeholder={t('settings.server.harnesses.password')}
|
||||
value={authPassword}
|
||||
onChange={(ev) => setAuthPassword(ev.target.value)}
|
||||
/>
|
||||
@@ -388,7 +398,7 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!authUsername || !authPassword}
|
||||
>
|
||||
Authenticate
|
||||
{t('settings.server.harnesses.authenticate')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -396,7 +406,7 @@ export const AIHarnessesSection = () => {
|
||||
<Input
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
placeholder="API Key"
|
||||
placeholder={t('settings.server.harnesses.apiKey')}
|
||||
value={authApiKey}
|
||||
onChange={(ev) => setAuthApiKey(ev.target.value)}
|
||||
autoFocus
|
||||
@@ -407,7 +417,7 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
|
||||
disabled={!authApiKey}
|
||||
>
|
||||
Authenticate
|
||||
{t('settings.server.harnesses.authenticate')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -423,7 +433,7 @@ export const AIHarnessesSection = () => {
|
||||
onClick={() => setAddingLocal(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Local Provider
|
||||
{t('settings.server.harnesses.connectLocal')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -431,7 +441,9 @@ export const AIHarnessesSection = () => {
|
||||
|
||||
{/* Remote Providers */}
|
||||
<div className="mt-6 text-xs text-duck-dark/50">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{t('settings.server.harnesses.remoteProviders')}
|
||||
</span>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{connectedProviders.map((provider) => (
|
||||
<div key={provider.providerId} className="flex flex-col gap-2">
|
||||
@@ -457,7 +469,7 @@ export const AIHarnessesSection = () => {
|
||||
type="button"
|
||||
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
|
||||
title="Edit key"
|
||||
title={t('settings.server.harnesses.editKey')}
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
|
||||
</button>
|
||||
@@ -465,7 +477,7 @@ export const AIHarnessesSection = () => {
|
||||
type="button"
|
||||
onClick={() => disconnectProvider(provider)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Disconnect provider"
|
||||
title={t('settings.server.harnesses.disconnect')}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
||||
</button>
|
||||
@@ -478,7 +490,7 @@ export const AIHarnessesSection = () => {
|
||||
<Input
|
||||
type="password"
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
|
||||
placeholder={getStoredMasked(provider.providerId) || t('settings.server.harnesses.enterKey')}
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
@@ -492,7 +504,7 @@ export const AIHarnessesSection = () => {
|
||||
type="submit"
|
||||
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||
title="Save key"
|
||||
title={t('settings.server.harnesses.saveKey')}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
@@ -501,7 +513,7 @@ export const AIHarnessesSection = () => {
|
||||
onClick={() => setEditingProvider(null)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -524,7 +536,7 @@ export const AIHarnessesSection = () => {
|
||||
<Input
|
||||
type="password"
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Enter API key"
|
||||
placeholder={t('settings.server.harnesses.enterKey')}
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
@@ -538,7 +550,7 @@ export const AIHarnessesSection = () => {
|
||||
type="submit"
|
||||
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||
title="Save key"
|
||||
title={t('settings.server.harnesses.saveKey')}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
@@ -547,7 +559,7 @@ export const AIHarnessesSection = () => {
|
||||
onClick={() => setEditingProvider(null)}
|
||||
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||
>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
@@ -561,14 +573,14 @@ export const AIHarnessesSection = () => {
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Provider
|
||||
{t('settings.server.harnesses.connectProvider')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
|
||||
<CommandInput placeholder="Search providers..." />
|
||||
<CommandInput placeholder={t('settings.server.harnesses.search')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No providers found.</CommandEmpty>
|
||||
<CommandEmpty>{t('settings.server.harnesses.none')}</CommandEmpty>
|
||||
<CommandGroup heading="Available Providers">
|
||||
{unconnectedProviders.map((provider) => (
|
||||
<CommandItem
|
||||
|
||||
+11
-11
@@ -3,6 +3,7 @@ import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* The two ways to load a change without leaving the browser.
|
||||
@@ -30,6 +31,7 @@ export function errorMessage(err: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
export const MaintenanceSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null);
|
||||
@@ -58,11 +60,11 @@ export const MaintenanceSection = () => {
|
||||
}>('/server-settings/pull', {});
|
||||
if (!res.updated) {
|
||||
setPulled(null);
|
||||
toast.success('Already up to date');
|
||||
toast.success(t('settings.server.maintenance.upToDate'));
|
||||
return;
|
||||
}
|
||||
setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs });
|
||||
toast.success(`Pulled ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`);
|
||||
toast.success(t('settings.server.maintenance.pulled', { count: res.commits.length }));
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, 'Pull failed'));
|
||||
} finally {
|
||||
@@ -81,7 +83,7 @@ export const MaintenanceSection = () => {
|
||||
setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`);
|
||||
// Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is
|
||||
// the difference between "it worked" and "why does my change not show".
|
||||
toast.success('Frontend rebuilt — refresh this page to see it');
|
||||
toast.success(t('settings.server.maintenance.rebuilt'));
|
||||
} catch (err) {
|
||||
// The client throws on any status >= 400 with the raw response body as its message, so a build
|
||||
// failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader
|
||||
@@ -96,7 +98,7 @@ export const MaintenanceSection = () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await client.post('/server-settings/restart', {});
|
||||
toast.success('Restarting — the page will reconnect on its own');
|
||||
toast.success(t('settings.server.maintenance.restarting'));
|
||||
} catch (err) {
|
||||
/**
|
||||
* Two failures arrive here and they are opposites.
|
||||
@@ -116,7 +118,7 @@ export const MaintenanceSection = () => {
|
||||
setRestarting(false);
|
||||
return;
|
||||
}
|
||||
toast.success('Restarting — the page will reconnect on its own');
|
||||
toast.success(t('settings.server.maintenance.restarting'));
|
||||
}
|
||||
// Left disabled on the success path, deliberately: the button must not become pressable again while
|
||||
// the server is coming back.
|
||||
@@ -126,14 +128,12 @@ export const MaintenanceSection = () => {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Update the platform</div>
|
||||
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.updateTitle')}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Pulls this server’s repository. Refuses if anything is uncommitted or the checkout has diverged — it
|
||||
will never discard local work.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Changes nothing that is running. Whatever the update needs is listed here, and stays your call.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('settings.server.maintenance.updateHint')}</p>
|
||||
{pulled && (
|
||||
<div className="mt-2 rounded-md border border-border bg-muted/40 p-2">
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
@@ -163,7 +163,7 @@ export const MaintenanceSection = () => {
|
||||
|
||||
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
|
||||
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.rebuild')}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session —
|
||||
refresh the page when it finishes.
|
||||
@@ -178,7 +178,7 @@ export const MaintenanceSection = () => {
|
||||
|
||||
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Restart the server</div>
|
||||
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.restart')}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding
|
||||
cannot see an edit to the backend.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type OcrConfig = {
|
||||
url: string;
|
||||
@@ -15,6 +16,7 @@ type OcrConfig = {
|
||||
};
|
||||
|
||||
export const OCRSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -59,9 +61,9 @@ export const OCRSection = () => {
|
||||
try {
|
||||
await client.put('/server-settings/ocr', { url, model });
|
||||
queryClient.invalidateQueries({ queryKey: ['OCR_CONFIG'] });
|
||||
toast.success('OCR settings saved');
|
||||
toast.success(t('settings.server.ocr.saved'));
|
||||
} catch {
|
||||
toast.error('Failed to save OCR settings');
|
||||
toast.error(t('settings.server.ocr.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -75,7 +77,7 @@ export const OCRSection = () => {
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success('Vision model server is reachable');
|
||||
toast.success(t('settings.server.ocr.reachable'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
@@ -91,12 +93,12 @@ export const OCRSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Vision Model Server URL</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.ocr.serverUrl')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
placeholder="http://localhost:64203"
|
||||
@@ -107,13 +109,13 @@ export const OCRSection = () => {
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchModels(url)}
|
||||
disabled={modelsLoading}
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh models"
|
||||
title={t('settings.server.ocr.refreshModels')}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${modelsLoading ? 'animate-spin' : ''}`}
|
||||
@@ -123,7 +125,7 @@ export const OCRSection = () => {
|
||||
{models.length > 0 ? (
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
<SelectValue placeholder={t('settings.server.ocr.selectModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
{models.map((m) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type Provider = 'resend' | 'smtp' | 'mailhog';
|
||||
|
||||
@@ -31,6 +32,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<SmtpConfig>> = {
|
||||
};
|
||||
|
||||
export const SMTPSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
@@ -92,9 +94,9 @@ export const SMTPSection = () => {
|
||||
try {
|
||||
await client.put('/server-settings/smtp', buildConfig());
|
||||
queryClient.invalidateQueries({ queryKey: ['SMTP_CONFIG'] });
|
||||
toast.success('SMTP settings saved');
|
||||
toast.success(t('settings.server.smtp.saved'));
|
||||
} catch {
|
||||
toast.error('Failed to save SMTP settings');
|
||||
toast.error(t('settings.server.smtp.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -105,7 +107,7 @@ export const SMTPSection = () => {
|
||||
setIsTestingConnection(true);
|
||||
try {
|
||||
await client.post('/server-settings/smtp/test-connection');
|
||||
toast.success('Connection successful');
|
||||
toast.success(t('settings.server.smtp.connected'));
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Connection failed';
|
||||
@@ -132,7 +134,7 @@ export const SMTPSection = () => {
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(`Test email sent to ${testEmail}`);
|
||||
toast.success(t('settings.server.smtp.testSent', { email: testEmail }));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
@@ -148,7 +150,7 @@ export const SMTPSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
|
||||
|
||||
const showSmtpFields = provider === 'smtp' || provider === 'mailhog';
|
||||
|
||||
@@ -156,22 +158,22 @@ export const SMTPSection = () => {
|
||||
<div className="grid gap-4">
|
||||
<form onSubmit={handleSave} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
|
||||
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600]">
|
||||
<SelectItem value="mailhog">MailHog (local testing)</SelectItem>
|
||||
<SelectItem value="resend">Resend</SelectItem>
|
||||
<SelectItem value="smtp">Custom SMTP</SelectItem>
|
||||
<SelectItem value="mailhog">{t('settings.server.smtp.mailhog')}</SelectItem>
|
||||
<SelectItem value="resend">{t('settings.server.smtp.resend')}</SelectItem>
|
||||
<SelectItem value="smtp">{t('settings.server.smtp.custom')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
|
||||
{provider === 'resend' && (
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">API Key</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.apiKey')}</span>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
@@ -186,7 +188,7 @@ export const SMTPSection = () => {
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Host</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.host')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
placeholder="smtp.example.com"
|
||||
@@ -195,7 +197,7 @@ export const SMTPSection = () => {
|
||||
/>
|
||||
</Label>
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Port</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.port')}</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
|
||||
@@ -208,16 +210,20 @@ export const SMTPSection = () => {
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Username</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">
|
||||
{t('settings.server.smtp.username')}
|
||||
</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
placeholder="user@example.com"
|
||||
placeholder={t('settings.server.smtp.userExample')}
|
||||
value={username}
|
||||
onChange={(ev) => setUsername(ev.target.value)}
|
||||
/>
|
||||
</Label>
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Password</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">
|
||||
{t('settings.server.smtp.password')}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
|
||||
@@ -228,7 +234,7 @@ export const SMTPSection = () => {
|
||||
</div>
|
||||
<Label className="flex items-center gap-3">
|
||||
<Switch checked={secure} onCheckedChange={setSecure} />
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">TLS / SSL</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.tls')}</span>
|
||||
</Label>
|
||||
</>
|
||||
)}
|
||||
@@ -237,7 +243,7 @@ export const SMTPSection = () => {
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">From Name</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromName')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
value={fromName}
|
||||
@@ -245,7 +251,7 @@ export const SMTPSection = () => {
|
||||
/>
|
||||
</Label>
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">From Email</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromEmail')}</span>
|
||||
<Input
|
||||
type="email"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
@@ -276,12 +282,14 @@ export const SMTPSection = () => {
|
||||
</form>
|
||||
|
||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Send Test Email</span>
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{t('settings.server.smtp.sendTest')}
|
||||
</span>
|
||||
<form onSubmit={handleTest} className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
type="email"
|
||||
className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1"
|
||||
placeholder="recipient@example.com"
|
||||
placeholder={t('settings.server.smtp.recipientExample')}
|
||||
value={testEmail}
|
||||
onChange={(ev) => setTestEmail(ev.target.value)}
|
||||
/>
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type SttConfig = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const STTSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -36,9 +38,9 @@ export const STTSection = () => {
|
||||
try {
|
||||
await client.put('/server-settings/stt', { url });
|
||||
queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] });
|
||||
toast.success('STT settings saved');
|
||||
toast.success(t('settings.server.stt.saved'));
|
||||
} catch {
|
||||
toast.error('Failed to save STT settings');
|
||||
toast.error(t('settings.server.stt.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -52,7 +54,7 @@ export const STTSection = () => {
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success('Whisper server is reachable');
|
||||
toast.success(t('settings.server.stt.reachable'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
@@ -68,12 +70,12 @@ export const STTSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Whisper Server URL</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.stt.serverUrl')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
placeholder="http://localhost:64201"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type Provider = 'openai' | 'elevenlabs';
|
||||
|
||||
@@ -33,6 +34,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<TtsConfig>> = {
|
||||
};
|
||||
|
||||
export const TTSSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -110,9 +112,9 @@ export const TTSSection = () => {
|
||||
try {
|
||||
await client.put('/server-settings/tts', buildConfig());
|
||||
queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] });
|
||||
toast.success('TTS settings saved');
|
||||
toast.success(t('settings.server.tts.saved'));
|
||||
} catch {
|
||||
toast.error('Failed to save TTS settings');
|
||||
toast.error(t('settings.server.tts.saveFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -143,27 +145,27 @@ export const TTSSection = () => {
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
audio.play();
|
||||
toast.success('Playing test audio');
|
||||
toast.success(t('settings.server.tts.playing'));
|
||||
} catch {
|
||||
toast.error('Test failed — could not reach TTS server');
|
||||
toast.error(t('settings.server.tts.testFailed'));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
|
||||
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
|
||||
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600]">
|
||||
<SelectItem value="openai">OpenAI-compatible (Kokoro, OpenAI, etc.)</SelectItem>
|
||||
<SelectItem value="elevenlabs">ElevenLabs</SelectItem>
|
||||
<SelectItem value="openai">{t('settings.server.tts.openaiCompatible')}</SelectItem>
|
||||
<SelectItem value="elevenlabs">{t('settings.server.tts.elevenLabs')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
@@ -195,7 +197,7 @@ export const TTSSection = () => {
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
placeholder={provider === 'openai' ? 'kokoro' : 'eleven_multilingual_v2'}
|
||||
@@ -205,13 +207,13 @@ export const TTSSection = () => {
|
||||
</Label>
|
||||
<Label className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.voice')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchVoices(provider, url, apiKey, model)}
|
||||
disabled={voicesLoading}
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
title={t('settings.server.tts.refreshVoices')}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
|
||||
@@ -221,7 +223,7 @@ export const TTSSection = () => {
|
||||
{voices.length > 0 ? (
|
||||
<Select value={voice} onValueChange={setVoice}>
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
|
||||
<SelectValue placeholder="Select a voice" />
|
||||
<SelectValue placeholder={t('settings.server.tts.selectVoice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
{voiceGroups.length > 0
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
|
||||
//
|
||||
@@ -65,6 +66,7 @@ type CreatedAccount = {
|
||||
};
|
||||
|
||||
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -118,16 +120,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{created.email} created</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy what you need before closing — none of it can be shown again.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.users.copyBeforeClosing')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Password</Label>
|
||||
<Label>{t('settings.users.password')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={created.password} className="font-mono" />
|
||||
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copy(created.password, t('settings.users.password'))}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -139,14 +144,14 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
|
||||
{created.osUser && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Linux account</Label>
|
||||
<Label>{t('settings.users.linuxAccount')}</Label>
|
||||
<Input readOnly value={created.osUser} className="font-mono" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{created.osSshPublicKey && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Their SSH public key</Label>
|
||||
<Label>{t('settings.users.sshKey')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
|
||||
<Button
|
||||
@@ -162,20 +167,20 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
permission error that says nothing about a missing key. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generated on the machine; the private half never leaves it.{' '}
|
||||
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
|
||||
<strong>{t('settings.users.addToGitea')}</strong> so they can push. Retrievable later from their row.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{created.osUserError && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
<div className="font-medium">The account works, but its Linux side did not finish</div>
|
||||
<div className="font-medium">{t('settings.users.linuxIncomplete')}</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" onClick={close}>
|
||||
Done
|
||||
{t('settings.users.done')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -185,7 +190,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Add account
|
||||
{t('settings.users.addAccount')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -194,19 +199,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">New account</h3>
|
||||
<h3 className="text-sm font-medium">{t('settings.users.newAccount')}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label={tUi('button.cancel')}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-user-email">Email</Label>
|
||||
<Label htmlFor="new-user-email">{t('settings.users.email')}</Label>
|
||||
<Input
|
||||
id="new-user-email"
|
||||
type="email"
|
||||
@@ -218,7 +223,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-user-name">Name</Label>
|
||||
<Label htmlFor="new-user-name">{t('settings.users.name')}</Label>
|
||||
<Input
|
||||
id="new-user-name"
|
||||
autoComplete="off"
|
||||
@@ -229,7 +234,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-user-username">Username</Label>
|
||||
<Label htmlFor="new-user-username">{t('settings.users.username')}</Label>
|
||||
<Input
|
||||
id="new-user-username"
|
||||
autoComplete="off"
|
||||
@@ -237,11 +242,11 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
onChange={(ev) => set('username')(ev.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.users.usernameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-user-role">Role</Label>
|
||||
<Label htmlFor="new-user-role">{t('settings.users.role')}</Label>
|
||||
<Select value={form.role} onValueChange={set('role')}>
|
||||
<SelectTrigger id="new-user-role">
|
||||
<SelectValue />
|
||||
@@ -261,7 +266,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="new-user-password">Password</Label>
|
||||
<Label htmlFor="new-user-password">{t('settings.users.password')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="new-user-password"
|
||||
@@ -276,7 +281,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
/>
|
||||
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
|
||||
<Dices className="h-4 w-4" />
|
||||
<span className="sr-only">Generate a password</span>
|
||||
<span className="sr-only">{t('settings.users.generatePassword')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -285,29 +290,27 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
disabled={!form.password}
|
||||
onClick={() => {
|
||||
void copyToClipboard(form.password);
|
||||
toast.success('Password copied');
|
||||
toast.success(t('settings.users.passwordCopied'));
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span className="sr-only">Copy the password</span>
|
||||
<span className="sr-only">{t('settings.users.copyPassword')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
At least 12 characters, with upper and lower case, a number and a symbol.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.users.passwordHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
|
||||
not replace it, because a key on a laptop is no use to an agent running on the server. */}
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="new-user-ssh">
|
||||
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
|
||||
{t('settings.users.sshKey')} <span className="ml-1 text-xs opacity-60">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="new-user-ssh"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
|
||||
placeholder={t('settings.users.sshPlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
value={form.sshPublicKey}
|
||||
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
|
||||
@@ -325,7 +328,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||
Create account
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={saving}>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+16
-10
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
|
||||
import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why.
|
||||
//
|
||||
@@ -37,6 +38,7 @@ type Level = 'none' | 'read' | 'write';
|
||||
const PERMISSIONS_KEY = ['ROLE_PERMISSIONS'];
|
||||
|
||||
export const PermissionsSection = () => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
@@ -78,7 +80,7 @@ export const PermissionsSection = () => {
|
||||
// The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
|
||||
// catch up without a reload.
|
||||
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY });
|
||||
toast.success(`Saved what ${activeRole}s can reach`);
|
||||
toast.success(t('settings.users.permissionsSaved', { role: activeRole }));
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not save');
|
||||
} finally {
|
||||
@@ -89,12 +91,12 @@ export const PermissionsSection = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading permissions…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingPermissions')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <div className="p-6 text-sm text-destructive">Could not load permissions.</div>;
|
||||
return <div className="p-6 text-sm text-destructive">{t('settings.users.permissionsFailed')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -103,7 +105,11 @@ export const PermissionsSection = () => {
|
||||
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
|
||||
buttons, because switching role mutates a draft rather than navigating. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-lg border p-1"
|
||||
role="tablist"
|
||||
aria-label={t('settings.users.role')}
|
||||
>
|
||||
{data.roles.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
@@ -128,9 +134,9 @@ export const PermissionsSection = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything is denied unless granted here. <strong>Read</strong> allows viewing, plus changes to things that are
|
||||
only ever the person’s own — their favourites, their playlists, their devices.
|
||||
<strong> Full</strong> allows everything within the app.
|
||||
{t('settings.users.deniedUnlessGranted')} <strong>{t('settings.users.read')}</strong> allows viewing, plus
|
||||
changes to things that are only ever the person’s own — their favourites, their playlists, their devices.
|
||||
<strong> {t('settings.users.full')}</strong> allows everything within the app.
|
||||
</p>
|
||||
|
||||
<div className="divide-y rounded-lg border">
|
||||
@@ -151,9 +157,9 @@ export const PermissionsSection = () => {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No access</SelectItem>
|
||||
<SelectItem value="read">Read</SelectItem>
|
||||
<SelectItem value="write">Full</SelectItem>
|
||||
<SelectItem value="none">{t('settings.users.noAccess')}</SelectItem>
|
||||
<SelectItem value="read">{t('settings.users.read')}</SelectItem>
|
||||
<SelectItem value="write">{t('settings.users.full')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { CreateUserForm } from './CreateUserForm';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type ManagedUser = {
|
||||
id: number;
|
||||
@@ -43,6 +44,7 @@ type UsersResponse = {
|
||||
const USERS_KEY = ['MANAGED_USERS'];
|
||||
|
||||
export const UsersSection = () => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||
@@ -77,11 +79,7 @@ export const UsersSection = () => {
|
||||
* reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it".
|
||||
*/
|
||||
const provisionLinux = async (user: ManagedUser) => {
|
||||
const key = window.prompt(
|
||||
`Linux account for ${user.email}.\n\n` +
|
||||
`Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`,
|
||||
'',
|
||||
);
|
||||
const key = window.prompt(t('settings.users.provisionPrompt', { email: user.email }), '');
|
||||
// Cancel is null; empty string is a deliberate "no change".
|
||||
if (key === null) return;
|
||||
|
||||
@@ -147,13 +145,13 @@ export const UsersSection = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts…
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingAccounts')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="text-sm text-destructive">Could not load the accounts.</div>;
|
||||
return <div className="text-sm text-destructive">{t('settings.users.accountsFailed')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -173,7 +171,12 @@ export const UsersSection = () => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span>
|
||||
{user.isOwner && <Crown className="h-3.5 w-3.5 shrink-0 text-duck-teal" aria-label="Server owner" />}
|
||||
{user.isOwner && (
|
||||
<Crown
|
||||
className="h-3.5 w-3.5 shrink-0 text-duck-teal"
|
||||
aria-label={t('settings.users.serverOwner')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
@@ -212,11 +215,13 @@ export const UsersSection = () => {
|
||||
size="icon"
|
||||
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
|
||||
disabled={busy}
|
||||
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`}
|
||||
aria-label={
|
||||
user.osUser ? t('settings.users.repairFor', { email: user.email }) : t('settings.users.createLinux')
|
||||
}
|
||||
title={
|
||||
user.osUser
|
||||
? `Linux account: ${user.osUser} — click to repair or replace their SSH key`
|
||||
: 'No Linux account — click to create one'
|
||||
? t('settings.users.linuxAccountFor', { osUser: user.osUser })
|
||||
: t('settings.users.noLinux')
|
||||
}
|
||||
onClick={() => void provisionLinux(user)}
|
||||
>
|
||||
@@ -231,11 +236,11 @@ export const UsersSection = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label={`Copy ${user.email}'s SSH public key`}
|
||||
title="Copy their SSH public key (add it to their Gitea account)"
|
||||
aria-label={t('settings.users.copySshFor', { email: user.email })}
|
||||
title={t('settings.users.copySshTitle')}
|
||||
onClick={() => {
|
||||
void copyToClipboard(user.osSshPublicKey!);
|
||||
toast.success('Public key copied');
|
||||
toast.success(t('settings.users.publicKeyCopied'));
|
||||
}}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
@@ -250,8 +255,8 @@ export const UsersSection = () => {
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
disabled={busy}
|
||||
aria-label={`Reset ${user.email}'s password`}
|
||||
title="Generate a new password — shown once, and signs them out everywhere"
|
||||
aria-label={t('settings.users.resetPasswordFor', { email: user.email })}
|
||||
title={t('settings.users.resetPasswordTitle')}
|
||||
onClick={() => setConfirmReset(user)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
|
||||
@@ -264,7 +269,7 @@ export const UsersSection = () => {
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
disabled={user.isOwner || busy}
|
||||
onClick={() => setConfirmDelete(user)}
|
||||
aria-label={`Remove ${user.email}`}
|
||||
aria-label={t('settings.users.removeFor', { email: user.email })}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -282,19 +287,18 @@ export const UsersSection = () => {
|
||||
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove {confirmDelete?.email}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and
|
||||
anything an installed plugin keeps for them. It cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>
|
||||
{t('settings.users.removeTitle', { email: confirmDelete?.email ?? '' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('settings.users.removeBody')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => confirmDelete && void remove(confirmDelete)}
|
||||
>
|
||||
Remove account
|
||||
{t('settings.users.removeAccount')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -305,23 +309,16 @@ export const UsersSection = () => {
|
||||
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('settings.users.resetTitle', { email: confirmReset?.email ?? '' })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up
|
||||
afterwards. Their existing password stops working immediately, and they are signed out everywhere.
|
||||
{confirmReset?.osUser ? (
|
||||
<>
|
||||
{' '}
|
||||
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
|
||||
unchanged.
|
||||
</>
|
||||
) : null}
|
||||
{t('settings.users.resetBody')}
|
||||
{confirmReset?.osUser ? ` ${t('settings.users.resetLinuxNote', { osUser: confirmReset.osUser })}` : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
|
||||
Generate a new password
|
||||
{t('settings.users.generateNewPassword')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -345,11 +342,11 @@ export const UsersSection = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label="Copy the new password"
|
||||
aria-label={t('settings.users.copyNewPassword')}
|
||||
onClick={() => {
|
||||
if (!newPassword) return;
|
||||
void copyToClipboard(newPassword.password).then((ok) =>
|
||||
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'),
|
||||
ok ? toast.success(t('settings.users.passwordCopied')) : toast.error(t('settings.users.copyFailed')),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -358,7 +355,7 @@ export const UsersSection = () => {
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => setNewPassword(null)}>{t('settings.users.done')}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type UntranslatedProps = {
|
||||
children: ReactNode;
|
||||
/** Why this text is not localized. Required so the decision is legible at the call site. */
|
||||
reason: 'foreign-ui-label';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text that is deliberately NOT localized, marked as such where it is written.
|
||||
*
|
||||
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
|
||||
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
|
||||
* what the reader sees on Google's screen, and Google localises by their Google account language rather
|
||||
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
|
||||
*
|
||||
* It exists as a component rather than a comment for two reasons. It states the decision at every call
|
||||
* site, where the next person editing the sentence will see it. And it gives the scanner something exact
|
||||
* to skip: without that, ~40 labels that MUST stay English count as debt forever, and the area can never
|
||||
* be held at zero — the same trap as counting `new Error()` messages.
|
||||
*
|
||||
* `reason` is a closed union on purpose. A second legitimate reason should be argued for and added here,
|
||||
* not smuggled in as free text.
|
||||
*/
|
||||
export const Untranslated = ({ children, className }: UntranslatedProps) => (
|
||||
<strong className={className}>{children}</strong>
|
||||
);
|
||||
@@ -6,9 +6,22 @@
|
||||
*/
|
||||
export type ShippedLocale = { code: string; label: string };
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = [
|
||||
{ code: 'en-GB', label: 'English (UK)' },
|
||||
{ code: 'pt-PT', label: 'Português (Portugal)' },
|
||||
];
|
||||
/**
|
||||
* The label is an AUTONYM — each language named in its own language, so "Português" reads as Portuguese
|
||||
* whichever locale the UI is currently in. That is the convention for a language picker and it is the
|
||||
* useful behaviour: somebody who cannot read the current language still has to find theirs.
|
||||
*
|
||||
* Computed rather than written down, for the same reason the spoken-language list is (see Languages.tsx):
|
||||
* `Intl` already knows every language's name in every locale, including its own.
|
||||
*/
|
||||
const autonym = (code: string): string => {
|
||||
const name = new Intl.DisplayNames([code], { type: 'language', fallback: 'code' }).of(code) ?? code;
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT'].map((code) => ({
|
||||
code,
|
||||
label: autonym(code),
|
||||
}));
|
||||
|
||||
export const FALLBACK_LOCALE = 'en-GB';
|
||||
|
||||
@@ -220,6 +220,235 @@
|
||||
"integrations": {
|
||||
"enterprise": "Enterprise",
|
||||
"personal": "Personal"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Loading…",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"voice": "Voice",
|
||||
"ocr": {
|
||||
"serverUrl": "Vision Model Server URL",
|
||||
"refreshModels": "Refresh models",
|
||||
"selectModel": "Select a model",
|
||||
"saved": "OCR settings saved",
|
||||
"saveFailed": "Failed to save OCR settings",
|
||||
"reachable": "Vision model server is reachable"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "Whisper Server URL",
|
||||
"saved": "STT settings saved",
|
||||
"saveFailed": "Failed to save STT settings",
|
||||
"reachable": "Whisper server is reachable"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-compatible (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Refresh voices",
|
||||
"selectVoice": "Select a voice",
|
||||
"saved": "TTS settings saved",
|
||||
"saveFailed": "Failed to save TTS settings",
|
||||
"playing": "Playing test audio",
|
||||
"testFailed": "Test failed — could not reach TTS server"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (local testing)",
|
||||
"resend": "Resend",
|
||||
"custom": "Custom SMTP",
|
||||
"apiKey": "API Key",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "From Name",
|
||||
"fromEmail": "From Email",
|
||||
"sendTest": "Send Test Email",
|
||||
"userExample": "user@example.com",
|
||||
"recipientExample": "recipient@example.com",
|
||||
"saved": "SMTP settings saved",
|
||||
"saveFailed": "Failed to save SMTP settings",
|
||||
"connected": "Connection successful",
|
||||
"testSent": "Test email sent to {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Update the platform",
|
||||
"updateHint": "Changes nothing that is running. Whatever the update needs is listed here, and stays your call.",
|
||||
"rebuild": "Rebuild the frontend",
|
||||
"restart": "Restart the server",
|
||||
"upToDate": "Already up to date",
|
||||
"rebuilt": "Frontend rebuilt — refresh this page to see it",
|
||||
"restarting": "Restarting — the page will reconnect on its own",
|
||||
"pulled": {
|
||||
"one": "Pulled {{count}} commit",
|
||||
"other": "Pulled {{count}} commits"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Checking…",
|
||||
"localProviders": "Local Providers",
|
||||
"remoteProviders": "Remote Providers",
|
||||
"connect": "Connect",
|
||||
"authenticate": "Authenticate",
|
||||
"connectLocal": "Connect Local Provider",
|
||||
"connectProvider": "Connect Provider",
|
||||
"none": "No providers found.",
|
||||
"updatePiMono": "Update Pi Mono",
|
||||
"removeProvider": "Remove provider",
|
||||
"namePlaceholder": "Name (e.g. My Ollama)",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"apiKey": "API Key",
|
||||
"editKey": "Edit key",
|
||||
"disconnect": "Disconnect provider",
|
||||
"saveKey": "Save key",
|
||||
"enterKey": "Enter API key",
|
||||
"search": "Search providers…",
|
||||
"connectFailed": "Failed to connect",
|
||||
"authFailed": "Authentication failed",
|
||||
"saveFailed": "Failed to save provider",
|
||||
"connectedTo": "Connected to {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Loading permissions…",
|
||||
"permissionsFailed": "Could not load permissions.",
|
||||
"deniedUnlessGranted": "Everything is denied unless granted here.",
|
||||
"read": "Read",
|
||||
"full": "Full",
|
||||
"noAccess": "No access",
|
||||
"role": "Role",
|
||||
"permissionsSaved": "Saved what {{role}}s can reach",
|
||||
"copyBeforeClosing": "Copy what you need before closing — none of it can be shown again.",
|
||||
"password": "Password",
|
||||
"linuxAccount": "Linux account",
|
||||
"sshKey": "Their SSH public key",
|
||||
"addToGitea": "Add this to their Gitea account",
|
||||
"linuxIncomplete": "The account works, but its Linux side did not finish",
|
||||
"done": "Done",
|
||||
"addAccount": "Add account",
|
||||
"newAccount": "New account",
|
||||
"email": "Email",
|
||||
"name": "Name",
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, numbers, dots, hyphens and underscores.",
|
||||
"generatePassword": "Generate a password",
|
||||
"copyPassword": "Copy the password",
|
||||
"passwordHint": "At least 12 characters, with upper and lower case, a number and a symbol.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
|
||||
"passwordCopied": "Password copied",
|
||||
"loadingAccounts": "Loading accounts…",
|
||||
"accountsFailed": "Could not load the accounts.",
|
||||
"removeAccount": "Remove account",
|
||||
"generateNewPassword": "Generate a new password",
|
||||
"serverOwner": "Server owner",
|
||||
"copySshTitle": "Copy their SSH public key (add it to their Gitea account)",
|
||||
"resetPasswordTitle": "Generate a new password — shown once, and signs them out everywhere",
|
||||
"copyNewPassword": "Copy the new password",
|
||||
"publicKeyCopied": "Public key copied",
|
||||
"copyFailed": "Could not copy — select it and copy by hand",
|
||||
"repairFor": "Repair {{email}}’s Linux account",
|
||||
"linuxAccountFor": "Linux account: {{osUser}} — click to repair or replace their SSH key",
|
||||
"copySshFor": "Copy {{email}}’s SSH public key",
|
||||
"resetPasswordFor": "Reset {{email}}’s password",
|
||||
"removeFor": "Remove {{email}}",
|
||||
"provisionPrompt": "Linux account for {{email}}.\n\nPaste an SSH public key to allow them to SSH in, or leave empty to keep the current one.",
|
||||
"createLinux": "Create a Linux account",
|
||||
"noLinux": "No Linux account — click to create one",
|
||||
"removeTitle": "Remove {{email}}?",
|
||||
"removeBody": "This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and anything an installed plugin keeps for them. It cannot be undone.",
|
||||
"resetTitle": "Reset the password for {{email}}?",
|
||||
"resetBody": "A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up afterwards. Their existing password stops working immediately, and they are signed out everywhere.",
|
||||
"resetLinuxNote": "Their Linux account ({{osUser}}) is not affected: it has no password, and SSH keys are unchanged."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "New device",
|
||||
"create": "Create",
|
||||
"none": "No devices yet.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Revoke",
|
||||
"delete": "Delete",
|
||||
"copyFailed": "Could not copy — select and copy manually",
|
||||
"createFailed": "Could not create the app password",
|
||||
"revoked": "Revoked — that device will stop syncing",
|
||||
"revokeFailed": "Could not revoke",
|
||||
"deleteFailed": "Could not delete",
|
||||
"copyField": "Copy {{field}}",
|
||||
"copied": "{{what}} copied"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Step-by-step setup guide",
|
||||
"step1": {
|
||||
"title": "Create a Google Cloud project",
|
||||
"body": "Open the project creation page, give it a name (for example “Officer”), and click:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Enable the APIs",
|
||||
"body": "Open the API library and search for each of these, then click:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Configure the OAuth consent screen",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Set your organization name, or “Officer”, as:",
|
||||
"supportEmail": "Set your admin email as:",
|
||||
"devContact": "Add your admin email under:",
|
||||
"save": "Then click:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Set the audience",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "If your team uses Google Workspace, choose — no verification needed:",
|
||||
"external": "Otherwise choose, and add your team’s emails under Test users (required while the app is unverified; limit of 100 test users):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Add scopes",
|
||||
"link": "Data Access",
|
||||
"body": "Open data access and search for and add the scopes below, then click:",
|
||||
"note": "Google classifies the calendar scope as sensitive and the Gmail scope as restricted. That is fine for Internal apps (Google Workspace) and for External apps in testing mode; publishing to production with restricted scopes requires Google verification."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Create OAuth credentials",
|
||||
"link": "Clients",
|
||||
"body": "Open the clients page and click:",
|
||||
"appType": "Application type:",
|
||||
"nameAnything": "Name: anything (for example “Officer”)",
|
||||
"redirectUris": "Authorized redirect URIs — add:",
|
||||
"thenClick": "Then click:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Copy the credentials",
|
||||
"body": "A dialog shows your client ID and client secret. Copy both and paste them into the fields below."
|
||||
},
|
||||
"scopeGmail": "Read emails",
|
||||
"scopeCalendar": "Read calendar events",
|
||||
"verifying": "Verifying credentials…",
|
||||
"valid": "Credentials valid",
|
||||
"invalid": "Invalid credentials",
|
||||
"verifyFailed": "Verification request failed",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectUriHint": "Add this URI to your Google OAuth client’s authorized redirect URIs",
|
||||
"saved": "Google OAuth configuration saved",
|
||||
"saveFailed": "Failed to save Google OAuth configuration"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Google Account",
|
||||
"hint": "Connect your Google account for Calendar, Contacts, and other Google services.",
|
||||
"connected": "Connected",
|
||||
"disconnect": "Disconnect",
|
||||
"connect": "Connect Google Account",
|
||||
"connectedToast": "Google account connected",
|
||||
"connectFailed": "Failed to connect Google account",
|
||||
"disconnectedToast": "Google account disconnected",
|
||||
"disconnectFailed": "Failed to disconnect Google account"
|
||||
},
|
||||
"apify": {
|
||||
"token": "API Token",
|
||||
"saved": "Apify API token saved",
|
||||
"saveFailed": "Failed to save Apify API token"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -255,7 +484,8 @@
|
||||
"calendar": "Calendar",
|
||||
"contacts": "Contacts",
|
||||
"appStore": "App store",
|
||||
"marketplace": "Marketplace"
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Code Editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
|
||||
@@ -344,5 +574,131 @@
|
||||
"submitted": "Bug report submitted",
|
||||
"failed": "Failed to submit bug report",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Welcome Back",
|
||||
"signInSubtitle": "Sign in to your account",
|
||||
"emailOrUsername": "Email or username",
|
||||
"password": "Password",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"passwordPlaceholder": "Your password",
|
||||
"forgotTitle": "Forgot Password",
|
||||
"forgotSubtitle": "Enter your email to receive a reset link",
|
||||
"email": "Email",
|
||||
"backToLogin": "Back to login",
|
||||
"checkEmail": "Check your email",
|
||||
"checkEmailHint": "Click it to reset your password.",
|
||||
"resetOk": "Password reset successfully. Please sign in.",
|
||||
"verifying": "Verifying…",
|
||||
"invalidLink": "Invalid Link",
|
||||
"invalidLinkHint": "This reset link is invalid or has expired.",
|
||||
"resetTitle": "Reset Password",
|
||||
"resetSubtitle": "Enter your new password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"minChars": "Min 12 characters",
|
||||
"confirmPlaceholder": "Confirm your password",
|
||||
"bootstrapTitle": "Welcome, Admin",
|
||||
"bootstrapSubtitle": "Create your administrator account",
|
||||
"name": "Name",
|
||||
"username": "Username",
|
||||
"namePlaceholder": "Your name",
|
||||
"adminEmailPlaceholder": "admin@example.com",
|
||||
"bootstrapOk": "Administrator account created. Please sign in.",
|
||||
"debug": {
|
||||
"controls": "Controls:",
|
||||
"camera": "Camera:",
|
||||
"model": "Model:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigation",
|
||||
"spotlight": "Spotlight — go to any screen, plugin or setting by name",
|
||||
"thisList": "This list",
|
||||
"themes": "Themes",
|
||||
"themesNote": "The browser applies as you move, so you watch the app change rather than reading names.",
|
||||
"openBrowser": "Open the theme browser",
|
||||
"prevNextTheme": "Previous / next theme, without opening anything",
|
||||
"starCurrent": "Star or unstar the current theme, from anywhere",
|
||||
"inBrowser": "In the theme browser",
|
||||
"moveList": "Move through the list",
|
||||
"applyKeepBrowsing": "Apply the focused theme and keep browsing",
|
||||
"restoreStart": "Put back the theme you started on",
|
||||
"starFocused": "Star or unstar the focused theme",
|
||||
"keepLooking": "Keep what you are looking at",
|
||||
"cancelRestore": "Cancel and restore",
|
||||
"inSpotlight": "In Spotlight",
|
||||
"moveResults": "Move through the results",
|
||||
"goThere": "Go there",
|
||||
"close": "Close",
|
||||
"chat": "Chat",
|
||||
"chatNote": "Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.",
|
||||
"prevNextPrompt": "Previous / next prompt you sent, shell-style",
|
||||
"send": "Send · on an empty composer with something queued, send that now",
|
||||
"newline": "Newline",
|
||||
"stopTurn": "Stop the turn that is generating"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "Running & Queued",
|
||||
"loading": "Loading…",
|
||||
"nothingRunning": "Nothing running",
|
||||
"history": "History",
|
||||
"clearAll": "Clear all",
|
||||
"selectJob": "Select a job",
|
||||
"notFound": "Job not found.",
|
||||
"backToJobs": "Back to jobs",
|
||||
"stop": "Stop",
|
||||
"testNotification": "Send a test notification to every configured channel",
|
||||
"search": "Search…",
|
||||
"deleteFinished": "Delete all finished jobs",
|
||||
"noChannels": "No channels configured",
|
||||
"notifyFailed": "Failed to send notification",
|
||||
"stopRequested": "Stop requested",
|
||||
"stopFailed": "Failed to stop",
|
||||
"titles": "Titles",
|
||||
"download": "Download",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"steps": "Steps",
|
||||
"selectStep": "Select a step or sub-task to view output",
|
||||
"noOutput": "No output yet",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Cost:",
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"queued": "Queued",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"stopped": "Stopped",
|
||||
"interrupted": "Interrupted"
|
||||
},
|
||||
"failedWithCode": "Failed (exit {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"backgroundTasks": "Background tasks",
|
||||
"detached": "Detached",
|
||||
"backToList": "Back to the list",
|
||||
"selectTask": "Select a task to follow its live output",
|
||||
"noneRunning": "none running"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Set up your passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Could not load this conversation: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "No matches",
|
||||
"emptyFile": "Empty file",
|
||||
"createFailed": "Could not create the {{kind}}",
|
||||
"deleteFailed": "Could not delete the {{kind}}",
|
||||
"searchPlaceholder": "Search {{kind}}…",
|
||||
"selectOne": "Select a {{kind}}",
|
||||
"selectToView": "Select a {{kind}} to view its contents",
|
||||
"delete": "Delete",
|
||||
"createPrompt": "Help me create the content for this new {{kind}} file",
|
||||
"improvePrompt": "Help me understand and improve this {{kind}} file"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,235 @@
|
||||
"integrations": {
|
||||
"enterprise": "Empresa",
|
||||
"personal": "Pessoal"
|
||||
},
|
||||
"server": {
|
||||
"loading": "A carregar…",
|
||||
"provider": "Fornecedor",
|
||||
"model": "Modelo",
|
||||
"voice": "Voz",
|
||||
"ocr": {
|
||||
"serverUrl": "URL do servidor do modelo de visão",
|
||||
"refreshModels": "Atualizar modelos",
|
||||
"selectModel": "Selecione um modelo",
|
||||
"saved": "Definições de OCR guardadas",
|
||||
"saveFailed": "Falha ao guardar as definições de OCR",
|
||||
"reachable": "O servidor do modelo de visão está acessível"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL do servidor Whisper",
|
||||
"saved": "Definições de STT guardadas",
|
||||
"saveFailed": "Falha ao guardar as definições de STT",
|
||||
"reachable": "O servidor Whisper está acessível"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatível com OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Atualizar vozes",
|
||||
"selectVoice": "Selecione uma voz",
|
||||
"saved": "Definições de TTS guardadas",
|
||||
"saveFailed": "Falha ao guardar as definições de TTS",
|
||||
"playing": "A reproduzir áudio de teste",
|
||||
"testFailed": "Teste falhou — não foi possível contactar o servidor de TTS"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (testes locais)",
|
||||
"resend": "Resend",
|
||||
"custom": "SMTP personalizado",
|
||||
"apiKey": "Chave de API",
|
||||
"host": "Servidor",
|
||||
"port": "Porta",
|
||||
"username": "Nome de utilizador",
|
||||
"password": "Palavra-passe",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "Nome do remetente",
|
||||
"fromEmail": "Email do remetente",
|
||||
"sendTest": "Enviar email de teste",
|
||||
"userExample": "utilizador@exemplo.com",
|
||||
"recipientExample": "destinatario@exemplo.com",
|
||||
"saved": "Definições de SMTP guardadas",
|
||||
"saveFailed": "Falha ao guardar as definições de SMTP",
|
||||
"connected": "Ligação estabelecida",
|
||||
"testSent": "Email de teste enviado para {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Atualizar a plataforma",
|
||||
"updateHint": "Não altera nada que esteja em execução. O que a atualização precisa está listado aqui, e a decisão é sua.",
|
||||
"rebuild": "Reconstruir o frontend",
|
||||
"restart": "Reiniciar o servidor",
|
||||
"upToDate": "Já está atualizado",
|
||||
"rebuilt": "Frontend reconstruído — atualize esta página para o ver",
|
||||
"restarting": "A reiniciar — a página volta a ligar-se sozinha",
|
||||
"pulled": {
|
||||
"one": "{{count}} commit obtido",
|
||||
"other": "{{count}} commits obtidos"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "A verificar…",
|
||||
"localProviders": "Fornecedores locais",
|
||||
"remoteProviders": "Fornecedores remotos",
|
||||
"connect": "Ligar",
|
||||
"authenticate": "Autenticar",
|
||||
"connectLocal": "Ligar fornecedor local",
|
||||
"connectProvider": "Ligar fornecedor",
|
||||
"none": "Nenhum fornecedor encontrado.",
|
||||
"updatePiMono": "Atualizar Pi Mono",
|
||||
"removeProvider": "Remover fornecedor",
|
||||
"namePlaceholder": "Nome (ex. O meu Ollama)",
|
||||
"username": "Nome de utilizador",
|
||||
"password": "Palavra-passe",
|
||||
"apiKey": "Chave de API",
|
||||
"editKey": "Editar chave",
|
||||
"disconnect": "Desligar fornecedor",
|
||||
"saveKey": "Guardar chave",
|
||||
"enterKey": "Introduza a chave de API",
|
||||
"search": "Pesquisar fornecedores…",
|
||||
"connectFailed": "Falha ao ligar",
|
||||
"authFailed": "Falha na autenticação",
|
||||
"saveFailed": "Falha ao guardar o fornecedor",
|
||||
"connectedTo": "Ligado a {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "A carregar permissões…",
|
||||
"permissionsFailed": "Não foi possível carregar as permissões.",
|
||||
"deniedUnlessGranted": "Tudo é negado a menos que seja concedido aqui.",
|
||||
"read": "Leitura",
|
||||
"full": "Total",
|
||||
"noAccess": "Sem acesso",
|
||||
"role": "Função",
|
||||
"permissionsSaved": "Guardado o que os {{role}} podem alcançar",
|
||||
"copyBeforeClosing": "Copie o que precisa antes de fechar — nada disto pode ser mostrado outra vez.",
|
||||
"password": "Palavra-passe",
|
||||
"linuxAccount": "Conta Linux",
|
||||
"sshKey": "A sua chave pública SSH",
|
||||
"addToGitea": "Adicione isto à conta Gitea dele",
|
||||
"linuxIncomplete": "A conta funciona, mas a parte Linux não ficou concluída",
|
||||
"done": "Concluído",
|
||||
"addAccount": "Adicionar conta",
|
||||
"newAccount": "Nova conta",
|
||||
"email": "Email",
|
||||
"name": "Nome",
|
||||
"username": "Nome de utilizador",
|
||||
"usernameHint": "Letras, números, pontos, hífenes e underscores.",
|
||||
"generatePassword": "Gerar uma palavra-passe",
|
||||
"copyPassword": "Copiar a palavra-passe",
|
||||
"passwordHint": "Pelo menos 12 caracteres, com maiúsculas e minúsculas, um número e um símbolo.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@portatil",
|
||||
"passwordCopied": "Palavra-passe copiada",
|
||||
"loadingAccounts": "A carregar contas…",
|
||||
"accountsFailed": "Não foi possível carregar as contas.",
|
||||
"removeAccount": "Remover conta",
|
||||
"generateNewPassword": "Gerar uma nova palavra-passe",
|
||||
"serverOwner": "Proprietário do servidor",
|
||||
"copySshTitle": "Copiar a sua chave pública SSH (adicione-a à conta Gitea dele)",
|
||||
"resetPasswordTitle": "Gerar uma nova palavra-passe — mostrada uma única vez, e termina a sessão em todo o lado",
|
||||
"copyNewPassword": "Copiar a nova palavra-passe",
|
||||
"publicKeyCopied": "Chave pública copiada",
|
||||
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
|
||||
"repairFor": "Reparar a conta Linux de {{email}}",
|
||||
"linuxAccountFor": "Conta Linux: {{osUser}} — clique para reparar ou substituir a chave SSH",
|
||||
"copySshFor": "Copiar a chave pública SSH de {{email}}",
|
||||
"resetPasswordFor": "Redefinir a palavra-passe de {{email}}",
|
||||
"removeFor": "Remover {{email}}",
|
||||
"provisionPrompt": "Conta Linux para {{email}}.\n\nCole uma chave pública SSH para lhe permitir aceder por SSH, ou deixe vazio para manter a atual.",
|
||||
"createLinux": "Criar uma conta Linux",
|
||||
"noLinux": "Sem conta Linux — clique para criar uma",
|
||||
"removeTitle": "Remover {{email}}?",
|
||||
"removeBody": "Isto elimina a conta e tudo o que lhe pertence — passkeys, painéis, layouts guardados e tudo o que qualquer plugin instalado guarde para ela. Não pode ser desfeito.",
|
||||
"resetTitle": "Redefinir a palavra-passe de {{email}}?",
|
||||
"resetBody": "É gerada uma nova palavra-passe e mostrada uma única vez — não é guardada em nenhum lugar e não pode ser consultada depois. A palavra-passe atual deixa de funcionar imediatamente, e a sessão é terminada em todo o lado.",
|
||||
"resetLinuxNote": "A conta Linux ({{osUser}}) não é afetada: não tem palavra-passe, e as chaves SSH ficam inalteradas."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Novo dispositivo",
|
||||
"create": "Criar",
|
||||
"none": "Ainda não há dispositivos.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Revogar",
|
||||
"delete": "Eliminar",
|
||||
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
|
||||
"createFailed": "Não foi possível criar a palavra-passe de aplicação",
|
||||
"revoked": "Revogada — esse dispositivo deixa de sincronizar",
|
||||
"revokeFailed": "Não foi possível revogar",
|
||||
"deleteFailed": "Não foi possível eliminar",
|
||||
"copyField": "Copiar {{field}}",
|
||||
"copied": "{{what}} copiado"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Guia de configuração passo a passo",
|
||||
"step1": {
|
||||
"title": "Criar um projeto Google Cloud",
|
||||
"body": "Abra a página de criação de projeto, dê-lhe um nome (por exemplo “Officer”) e clique em:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Ativar as APIs",
|
||||
"body": "Abra a biblioteca de APIs, procure cada uma destas e clique em:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Configurar o ecrã de consentimento OAuth",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Defina o nome da sua organização, ou “Officer”, como:",
|
||||
"supportEmail": "Defina o email do administrador como:",
|
||||
"devContact": "Adicione o email do administrador em:",
|
||||
"save": "Depois clique em:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Definir o público",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Se a sua equipa usa Google Workspace, escolha — não é necessária verificação:",
|
||||
"external": "Caso contrário escolha, e adicione os emails da sua equipa em Test users (obrigatório enquanto a aplicação não estiver verificada; limite de 100 utilizadores de teste):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Adicionar âmbitos",
|
||||
"link": "Data Access",
|
||||
"body": "Abra o acesso a dados, procure e adicione os âmbitos abaixo e depois clique em:",
|
||||
"note": "A Google classifica o âmbito do calendário como sensível e o do Gmail como restrito. Isso não é problema para aplicações Internal (Google Workspace) nem para aplicações External em modo de teste; publicar em produção com âmbitos restritos exige verificação pela Google."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Criar credenciais OAuth",
|
||||
"link": "Clients",
|
||||
"body": "Abra a página de clientes e clique em:",
|
||||
"appType": "Tipo de aplicação:",
|
||||
"nameAnything": "Nome: qualquer um (por exemplo “Officer”)",
|
||||
"redirectUris": "URIs de redirecionamento autorizados — adicione:",
|
||||
"thenClick": "Depois clique em:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Copiar as credenciais",
|
||||
"body": "Uma caixa de diálogo mostra o seu client ID e client secret. Copie ambos e cole-os nos campos abaixo."
|
||||
},
|
||||
"scopeGmail": "Ler emails",
|
||||
"scopeCalendar": "Ler eventos do calendário",
|
||||
"verifying": "A verificar credenciais…",
|
||||
"valid": "Credenciais válidas",
|
||||
"invalid": "Credenciais inválidas",
|
||||
"verifyFailed": "O pedido de verificação falhou",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"redirectUri": "URI de redirecionamento",
|
||||
"redirectUriHint": "Adicione este URI aos URIs de redirecionamento autorizados do seu cliente OAuth da Google",
|
||||
"saved": "Configuração OAuth da Google guardada",
|
||||
"saveFailed": "Falha ao guardar a configuração OAuth da Google"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Conta Google",
|
||||
"hint": "Ligue a sua conta Google para o Calendário, Contactos e outros serviços Google.",
|
||||
"connected": "Ligada",
|
||||
"disconnect": "Desligar",
|
||||
"connect": "Ligar conta Google",
|
||||
"connectedToast": "Conta Google ligada",
|
||||
"connectFailed": "Falha ao ligar a conta Google",
|
||||
"disconnectedToast": "Conta Google desligada",
|
||||
"disconnectFailed": "Falha ao desligar a conta Google"
|
||||
},
|
||||
"apify": {
|
||||
"token": "Token de API",
|
||||
"saved": "Token de API do Apify guardado",
|
||||
"saveFailed": "Falha ao guardar o token de API do Apify"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -255,7 +484,8 @@
|
||||
"calendar": "Calendário",
|
||||
"contacts": "Contactos",
|
||||
"appStore": "Loja de aplicações",
|
||||
"marketplace": "Marketplace"
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Editor de código"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
@@ -344,5 +574,131 @@
|
||||
"submitted": "Comunicação de erro enviada",
|
||||
"failed": "Falha ao enviar a comunicação de erro",
|
||||
"submit": "Enviar"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Bem-vindo de volta",
|
||||
"signInSubtitle": "Inicie sessão na sua conta",
|
||||
"emailOrUsername": "Email ou nome de utilizador",
|
||||
"password": "Palavra-passe",
|
||||
"forgotPassword": "Esqueceu-se da palavra-passe?",
|
||||
"emailPlaceholder": "utilizador@exemplo.com",
|
||||
"passwordPlaceholder": "A sua palavra-passe",
|
||||
"forgotTitle": "Recuperar palavra-passe",
|
||||
"forgotSubtitle": "Introduza o seu email para receber um link de recuperação",
|
||||
"email": "Email",
|
||||
"backToLogin": "Voltar ao início de sessão",
|
||||
"checkEmail": "Verifique o seu email",
|
||||
"checkEmailHint": "Clique nele para redefinir a palavra-passe.",
|
||||
"resetOk": "Palavra-passe redefinida com sucesso. Inicie sessão.",
|
||||
"verifying": "A verificar…",
|
||||
"invalidLink": "Link inválido",
|
||||
"invalidLinkHint": "Este link de recuperação é inválido ou expirou.",
|
||||
"resetTitle": "Redefinir palavra-passe",
|
||||
"resetSubtitle": "Introduza a sua nova palavra-passe",
|
||||
"confirmPassword": "Confirmar palavra-passe",
|
||||
"minChars": "Mínimo 12 caracteres",
|
||||
"confirmPlaceholder": "Confirme a sua palavra-passe",
|
||||
"bootstrapTitle": "Bem-vindo, Administrador",
|
||||
"bootstrapSubtitle": "Crie a sua conta de administrador",
|
||||
"name": "Nome",
|
||||
"username": "Nome de utilizador",
|
||||
"namePlaceholder": "O seu nome",
|
||||
"adminEmailPlaceholder": "admin@exemplo.com",
|
||||
"bootstrapOk": "Conta de administrador criada. Inicie sessão.",
|
||||
"debug": {
|
||||
"controls": "Controlos:",
|
||||
"camera": "Câmara:",
|
||||
"model": "Modelo:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navegação",
|
||||
"spotlight": "Spotlight — ir para qualquer ecrã, plugin ou definição pelo nome",
|
||||
"thisList": "Esta lista",
|
||||
"themes": "Temas",
|
||||
"themesNote": "O explorador aplica à medida que se move, para que veja a aplicação mudar em vez de ler nomes.",
|
||||
"openBrowser": "Abrir o explorador de temas",
|
||||
"prevNextTheme": "Tema anterior / seguinte, sem abrir nada",
|
||||
"starCurrent": "Marcar ou desmarcar o tema atual, a partir de qualquer lugar",
|
||||
"inBrowser": "No explorador de temas",
|
||||
"moveList": "Percorrer a lista",
|
||||
"applyKeepBrowsing": "Aplicar o tema focado e continuar a explorar",
|
||||
"restoreStart": "Voltar ao tema em que começou",
|
||||
"starFocused": "Marcar ou desmarcar o tema focado",
|
||||
"keepLooking": "Manter o que está a ver",
|
||||
"cancelRestore": "Cancelar e restaurar",
|
||||
"inSpotlight": "No Spotlight",
|
||||
"moveResults": "Percorrer os resultados",
|
||||
"goThere": "Ir para lá",
|
||||
"close": "Fechar",
|
||||
"chat": "Chat",
|
||||
"chatNote": "A recuperação só usa a tecla de seta num compositor VAZIO, e qualquer outra tecla termina a recuperação — pelo que um rascunho nunca é substituído.",
|
||||
"prevNextPrompt": "Prompt anterior / seguinte que enviou, à maneira da shell",
|
||||
"send": "Enviar · num compositor vazio com algo em fila, enviar isso agora",
|
||||
"newline": "Nova linha",
|
||||
"stopTurn": "Parar o turno que está a gerar"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "Em execução e em fila",
|
||||
"loading": "A carregar…",
|
||||
"nothingRunning": "Nada em execução",
|
||||
"history": "Histórico",
|
||||
"clearAll": "Limpar tudo",
|
||||
"selectJob": "Selecione um trabalho",
|
||||
"notFound": "Trabalho não encontrado.",
|
||||
"backToJobs": "Voltar aos trabalhos",
|
||||
"stop": "Parar",
|
||||
"testNotification": "Enviar uma notificação de teste para todos os canais configurados",
|
||||
"search": "Pesquisar…",
|
||||
"deleteFinished": "Eliminar todos os trabalhos terminados",
|
||||
"noChannels": "Nenhum canal configurado",
|
||||
"notifyFailed": "Falha ao enviar a notificação",
|
||||
"stopRequested": "Paragem pedida",
|
||||
"stopFailed": "Falha ao parar",
|
||||
"titles": "Títulos",
|
||||
"download": "Descarregar",
|
||||
"input": "Entrada",
|
||||
"output": "Saída",
|
||||
"steps": "Passos",
|
||||
"selectStep": "Selecione um passo ou subtarefa para ver a saída",
|
||||
"noOutput": "Ainda não há saída",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Custo:",
|
||||
"status": {
|
||||
"running": "Em execução",
|
||||
"queued": "Em fila",
|
||||
"completed": "Concluído",
|
||||
"failed": "Falhou",
|
||||
"stopped": "Parado",
|
||||
"interrupted": "Interrompido"
|
||||
},
|
||||
"failedWithCode": "Falhou (saída {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Atividade",
|
||||
"backgroundTasks": "Tarefas em segundo plano",
|
||||
"detached": "Destacada",
|
||||
"backToList": "Voltar à lista",
|
||||
"selectTask": "Selecione uma tarefa para seguir a sua saída ao vivo",
|
||||
"noneRunning": "nenhuma em execução"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Configure a sua passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Não foi possível carregar esta conversa: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Sem correspondências",
|
||||
"emptyFile": "Ficheiro vazio",
|
||||
"createFailed": "Não foi possível criar o {{kind}}",
|
||||
"deleteFailed": "Não foi possível eliminar o {{kind}}",
|
||||
"searchPlaceholder": "Pesquisar {{kind}}…",
|
||||
"selectOne": "Selecione um {{kind}}",
|
||||
"selectToView": "Selecione um {{kind}} para ver o seu conteúdo",
|
||||
"delete": "Eliminar",
|
||||
"createPrompt": "Ajuda-me a criar o conteúdo deste novo ficheiro de {{kind}}",
|
||||
"improvePrompt": "Ajuda-me a compreender e melhorar este ficheiro de {{kind}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AppTranslationKey } from '@/lib/i18n';
|
||||
|
||||
/*
|
||||
Every keyboard shortcut the shell owns, as data.
|
||||
|
||||
@@ -22,92 +24,97 @@
|
||||
export type Shortcut = {
|
||||
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
|
||||
keys: string;
|
||||
description: string;
|
||||
/** A catalog key. This is data with no component around it, so ShortcutsModal resolves it. */
|
||||
descriptionKey: AppTranslationKey;
|
||||
/** The file whose handler actually implements it, until this registry is authoritative. */
|
||||
implementedIn: string;
|
||||
};
|
||||
|
||||
export type ShortcutSection = {
|
||||
title: string;
|
||||
titleKey: AppTranslationKey;
|
||||
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
|
||||
note?: string;
|
||||
noteKey?: AppTranslationKey;
|
||||
shortcuts: Shortcut[];
|
||||
};
|
||||
|
||||
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
titleKey: 'shortcuts.navigation',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Shift+Space',
|
||||
description: 'Spotlight — go to any screen, plugin or setting by name',
|
||||
descriptionKey: 'shortcuts.spotlight',
|
||||
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
|
||||
},
|
||||
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
|
||||
{ keys: 'Ctrl+?', descriptionKey: 'shortcuts.thisList', implementedIn: 'Layout/ShortcutsModal.tsx' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Themes',
|
||||
note: 'The browser applies as you move, so you watch the app change rather than reading names.',
|
||||
titleKey: 'shortcuts.themes',
|
||||
noteKey: 'shortcuts.themesNote',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Ctrl+Shift+Space',
|
||||
description: 'Open the theme browser',
|
||||
descriptionKey: 'shortcuts.openBrowser',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl+Shift+← / →',
|
||||
description: 'Previous / next theme, without opening anything',
|
||||
descriptionKey: 'shortcuts.prevNextTheme',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl+Shift+F',
|
||||
description: 'Star or unstar the current theme, from anywhere',
|
||||
descriptionKey: 'shortcuts.starCurrent',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In the theme browser',
|
||||
titleKey: 'shortcuts.inBrowser',
|
||||
shortcuts: [
|
||||
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'j / k ↑ / ↓', descriptionKey: 'shortcuts.moveList', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{
|
||||
keys: 'l →',
|
||||
description: 'Apply the focused theme and keep browsing',
|
||||
descriptionKey: 'shortcuts.applyKeepBrowsing',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'h ←', descriptionKey: 'shortcuts.restoreStart', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'f', descriptionKey: 'shortcuts.starFocused', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Enter', descriptionKey: 'shortcuts.keepLooking', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Esc', descriptionKey: 'shortcuts.cancelRestore', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In Spotlight',
|
||||
titleKey: 'shortcuts.inSpotlight',
|
||||
shortcuts: [
|
||||
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: '↑ / ↓', descriptionKey: 'shortcuts.moveResults', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Enter', descriptionKey: 'shortcuts.goThere', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Esc', descriptionKey: 'shortcuts.close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Chat',
|
||||
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
|
||||
titleKey: 'shortcuts.chat',
|
||||
noteKey: 'shortcuts.chatNote',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: '↑ / ↓',
|
||||
description: 'Previous / next prompt you sent, shell-style',
|
||||
descriptionKey: 'shortcuts.prevNextPrompt',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Enter',
|
||||
description: 'Send · on an empty composer with something queued, send that now',
|
||||
descriptionKey: 'shortcuts.send',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Shift+Enter',
|
||||
descriptionKey: 'shortcuts.newline',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
|
||||
{
|
||||
keys: 'Esc',
|
||||
description: 'Stop the turn that is generating',
|
||||
descriptionKey: 'shortcuts.stopTurn',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
disarmCommandHqWatcher,
|
||||
recordCommandHqTurn,
|
||||
ingestSpawnedChildren,
|
||||
parentCommanderOf,
|
||||
recordSpawnedOutcome,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
|
||||
@@ -475,3 +475,44 @@ export async function recordSpawnedOutcome(
|
||||
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export type SpawnParent = {
|
||||
/** The commander's transcript id — what `/command-hq/<id>` opens. */
|
||||
sessionId: string;
|
||||
/** What the owner called it, or null for a commander filed before the column existed. */
|
||||
name: string | null;
|
||||
firstSeenAt: string;
|
||||
finishedAt: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which commander spawned this session, if any.
|
||||
*
|
||||
* The same row `ingestSpawnedChildren` writes, read from the other end — so this needed no new table and no
|
||||
* new write path. A commander's children were already recorded because deleting one has to take them with
|
||||
* it; that record answers "who is my parent" for free, and nothing was asking.
|
||||
*
|
||||
* Null for almost every session, and that is the normal case rather than a miss: a chat somebody opened
|
||||
* themselves has no parent, and a session spawned by anything other than a commander has no row here. The
|
||||
* caller must treat absence as "not spawned", never as "lookup failed".
|
||||
*/
|
||||
export async function parentCommanderOf(spawnedSessionId: string): Promise<SpawnParent | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
name: commandHqSessions.name,
|
||||
firstSeenAt: commandHqSpawned.firstSeenAt,
|
||||
finishedAt: commandHqSpawned.finishedAt,
|
||||
})
|
||||
.from(commandHqSpawned)
|
||||
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
|
||||
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
firstSeenAt: row.firstSeenAt.toISOString(),
|
||||
finishedAt: row.finishedAt ? row.finishedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getChatNotifyPreferences,
|
||||
setChatNotifyPreference,
|
||||
forgetChatSessions,
|
||||
parentCommanderOf,
|
||||
} from 'officerdb';
|
||||
import type { NotifyPreference } from 'officerdb/types';
|
||||
import { setNudge, getNudge, cancelNudge } from 'officerdb';
|
||||
@@ -273,6 +274,15 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
travels with the conversation.
|
||||
*/
|
||||
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
|
||||
/*
|
||||
Which commander spawned this, if any — the row `ingestSpawnedChildren` writes, read from the other
|
||||
end. A spawned agent has always been fully readable here; what it could never say was who started
|
||||
it, so opening one gave no way back to the conversation that owns it.
|
||||
|
||||
Null for almost every session and that is the normal case, not a miss: a chat somebody opened
|
||||
themselves has no parent. Never fatal — a session must render when this lookup will not.
|
||||
*/
|
||||
spawnedBy: await parentCommanderOf(id).catch(() => null),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { chatIdentity } from '../chat/chat';
|
||||
import { checkBudget } from './budget';
|
||||
|
||||
/*
|
||||
POST /sub-agents/spawn — an agent asks Officer to run another agent.
|
||||
|
||||
── Why this exists rather than leaning on the harness ──
|
||||
|
||||
A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher
|
||||
loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a
|
||||
commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what
|
||||
it started.
|
||||
|
||||
So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every
|
||||
caller gets the same one, and none of it changes when a CLI does.
|
||||
|
||||
── The condition that comes before the feature ──
|
||||
|
||||
`checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are
|
||||
spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the
|
||||
last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's
|
||||
conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a
|
||||
floor: never spend the last 15%.
|
||||
|
||||
── What it is NOT ──
|
||||
|
||||
Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any
|
||||
good is the caller's problem, and a caller that wants three tries writes three calls — because a retry
|
||||
inside here would be a policy nobody chose, applied to work this endpoint cannot read.
|
||||
*/
|
||||
|
||||
export const subAgentsRouter = createRouter();
|
||||
|
||||
const MAX_PROMPT = 100_000;
|
||||
|
||||
subAgentsRouter.post('/spawn', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean };
|
||||
|
||||
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`);
|
||||
|
||||
/*
|
||||
Refused BEFORE anything is spawned, and the reason travels with the refusal.
|
||||
|
||||
A caller that gets "no room" needs to know which window and how close, or its only move is to try again
|
||||
immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now",
|
||||
not a "not you".
|
||||
*/
|
||||
const budget = await checkBudget();
|
||||
if (!budget.ok) {
|
||||
return ctx.json(
|
||||
{ error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem },
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
The identity is resolved from the TOKEN, never from the body.
|
||||
|
||||
`chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same
|
||||
resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to
|
||||
run in.
|
||||
*/
|
||||
const who = await chatIdentity(user);
|
||||
|
||||
// Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first
|
||||
// reports one, which is after this returns — see the note in the response below.
|
||||
const sessionKey = crypto.randomUUID();
|
||||
const spawn = {
|
||||
userId: user.id,
|
||||
email: who.email,
|
||||
username: user.username ?? who.email.split('@')[0] ?? String(user.id),
|
||||
prompt,
|
||||
sessionKey,
|
||||
...(body?.cwd ? { cwd: body.cwd } : {}),
|
||||
...(body?.model ? { model: body.model } : {}),
|
||||
/*
|
||||
Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional
|
||||
one let a call site forget it, and forgetting it means a member's agent silently running as the owner in
|
||||
the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says
|
||||
there is no Linux user to run as.
|
||||
*/
|
||||
member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
/*
|
||||
Two modes, and the default is the one a loop wants.
|
||||
|
||||
`wait: false` returns as soon as the agent is running, so a watcher can start work and get on with
|
||||
watching. `wait: true` blocks for the answer, which is right for a question with a short answer and
|
||||
wrong for anything that touches a fleet — the request would hold a socket open for the length of the
|
||||
work.
|
||||
*/
|
||||
if (body?.wait === true) {
|
||||
const result = await sidecar.spawnClaude(spawn);
|
||||
return ctx.json({ sessionKey, result });
|
||||
}
|
||||
await sidecar.spawnClaudeStreaming(spawn);
|
||||
/*
|
||||
`sessionKey` and not a transcript id, because the transcript does not exist yet.
|
||||
|
||||
The agent announces its own uuid on its first turn, which is after this responds. A caller that needs
|
||||
the transcript — to open `/chat/<id>`, or to record what it started — reads it from the live session
|
||||
list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than
|
||||
saying which id this is.
|
||||
*/
|
||||
return ctx.json({ sessionKey, running: true });
|
||||
} catch (err) {
|
||||
// The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it
|
||||
// later is the correct response, which is a distinction a caller can act on.
|
||||
// 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct
|
||||
// response — a distinction the caller can act on.
|
||||
throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sub-agents/budget — is there room to start one, without starting one.
|
||||
*
|
||||
* Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle
|
||||
* than be refused mid-flight asks this first; the spawn checks again regardless, because between the two
|
||||
* calls somebody else may have spent the window.
|
||||
*/
|
||||
subAgentsRouter.get('/budget', async (ctx) => {
|
||||
const budget = await checkBudget();
|
||||
return ctx.json(budget);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { subAgentsRouter } from './api/sub-agents/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
@@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
['/sub-agents', subAgentsRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
|
||||
@@ -241,8 +241,19 @@ describe('kinds', () => {
|
||||
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
|
||||
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
|
||||
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
|
||||
//
|
||||
// `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of
|
||||
// itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and
|
||||
// passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the
|
||||
// same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that
|
||||
// chooses a directory, so there is nothing for a caller to point somewhere else.
|
||||
test('confined is a short, deliberate list', () => {
|
||||
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
|
||||
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([
|
||||
'terminal',
|
||||
'chat',
|
||||
'files',
|
||||
'sub-agents',
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin permissions are never grantable', () => {
|
||||
|
||||
@@ -360,6 +360,24 @@ const CORE_REGISTRY: Permission[] = [
|
||||
person who should decide whether somebody may put a file in your home is you, not whoever administers
|
||||
roles. This grant only says the feature is reachable at all.
|
||||
*/
|
||||
/*
|
||||
`confined`, and for the same reason chat is.
|
||||
|
||||
Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity`
|
||||
from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for
|
||||
the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account
|
||||
whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home.
|
||||
|
||||
Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as
|
||||
whoever called it, which is the whole distinction.
|
||||
*/
|
||||
{
|
||||
key: 'sub-agents',
|
||||
label: 'Sub-agents',
|
||||
description: 'Start another agent from a prompt, subject to the account budget',
|
||||
kind: 'confined',
|
||||
api: ['/sub-agents'],
|
||||
},
|
||||
{
|
||||
key: 'sendoff',
|
||||
label: 'SendOff',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
|
||||
import { useChat } from '../../hooks/useChat';
|
||||
@@ -21,8 +22,30 @@ type ResumeState = {
|
||||
offset: number;
|
||||
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
|
||||
isCommander: boolean;
|
||||
/** The commander that started this, when one did — see `SpawnedByBar`. */
|
||||
spawnedBy?: { sessionId: string; name: string | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Started by <commander>", above a transcript that was spawned rather than opened.
|
||||
*
|
||||
* A spawned agent has always rendered here perfectly — it writes an ordinary transcript and this view reads
|
||||
* it like any other. What it could never say was where it came from, so somebody who followed a link into
|
||||
* one had no way back to the conversation that owns it, and no way to tell it apart from a chat they had
|
||||
* started themselves and forgotten.
|
||||
*
|
||||
* Renders nothing for the overwhelming majority of sessions, which have no parent. That is the normal case
|
||||
* and it does not want a bar saying so.
|
||||
*/
|
||||
const SpawnedByBar = ({ parent }: { parent: { sessionId: string; name: string | null } }) => (
|
||||
<div className="shrink-0 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
|
||||
Started by{' '}
|
||||
<Link to={`/command-hq/${parent.sessionId}`} className="text-foreground/80 underline hover:text-foreground">
|
||||
{parent.name ?? 'a commander'}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
|
||||
// on scroll. Matches the /chat screen.
|
||||
const CHAT_TAIL = 20;
|
||||
@@ -105,6 +128,7 @@ const ChatPanelInner = ({
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<AgentIdentityBar panel={agentPanel} />
|
||||
{resume?.spawnedBy && <SpawnedByBar parent={resume.spawnedBy} />}
|
||||
<EmbeddableChat
|
||||
className="min-h-0 flex-1"
|
||||
chat={chat}
|
||||
@@ -234,6 +258,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
||||
total: detail.total,
|
||||
offset: detail.offset,
|
||||
isCommander: detail.isCommander === true,
|
||||
spawnedBy: detail.spawnedBy ?? null,
|
||||
});
|
||||
} catch {
|
||||
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
|
||||
|
||||
@@ -104,6 +104,14 @@ export type ClaudeSessionDetail = {
|
||||
* to decide it from the route, so the same commander opened in /chat quietly lost it.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
/**
|
||||
* The commander that spawned this session, if one did.
|
||||
*
|
||||
* Read from the row a commander already keeps of its children — so this is the parent side of a link that
|
||||
* existed for a different reason (deleting a commander has to take its children with it). Absent for
|
||||
* almost every conversation, and absence means "nobody spawned this", never "the lookup failed".
|
||||
*/
|
||||
spawnedBy?: { sessionId: string; name: string | null; firstSeenAt: string; finishedAt: string | null } | null;
|
||||
};
|
||||
|
||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: sub-agents/an-api-endpoint-for-spawning-an-agent-with-a
|
||||
title: "An API endpoint for spawning an agent with a prompt"
|
||||
title: 'An API endpoint for spawning an agent with a prompt'
|
||||
theme: sub-agents
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 1
|
||||
@@ -28,7 +28,6 @@ what Officer fixes.
|
||||
optional.** An endpoint that spawns freely is exactly how a weekly limit gets spent by something nobody
|
||||
was watching.
|
||||
|
||||
|
||||
---
|
||||
|
||||
**2026-08-21, narrowing this considerably.** The endpoint largely exists:
|
||||
@@ -43,3 +42,28 @@ already respects the window.
|
||||
|
||||
What is genuinely left is the SURFACE an agent can call — MCP tool or authenticated route — and how output
|
||||
returns to the caller. That is the remaining work on this item; the mechanism underneath it is done.
|
||||
|
||||
---
|
||||
|
||||
**Closed 2026-08-23.** `POST /api/sub-agents/spawn` — prompt, optional cwd and model, and either the session
|
||||
key immediately or the result if you ask it to wait. `GET /api/sub-agents/budget` answers whether there is
|
||||
room without consuming any.
|
||||
|
||||
Verified both modes against the live server rather than reasoned about: the synchronous call returned the
|
||||
four words the agent was told to say, and the async call returned in milliseconds with the agent's file on
|
||||
disk five seconds later.
|
||||
|
||||
Three things settled while building it, each worth not re-deriving:
|
||||
|
||||
- **The budget check is a precondition, not a refinement.** It runs before the spawn and refuses 429 naming
|
||||
the window. `checkBudget()` already existed and was already tested, which is the only reason this was a
|
||||
short job.
|
||||
- **`confined`, and true on the day it shipped.** The registry test's own comment records that `chat` sat on
|
||||
that list for a day before its turns actually ran as the member. This one inherits the mechanism rather
|
||||
than claiming it — `chatIdentity` off the token, `member: { osUser, home }` to the sidecar — and no path in
|
||||
the endpoint chooses a directory.
|
||||
- **`member` is passed explicitly, never omitted.** The field is required-but-may-be-undefined on purpose:
|
||||
optional let a call site forget it, and forgetting it means a member's agent running as the owner in the
|
||||
owner's home.
|
||||
|
||||
What it deliberately is not: a queue, a supervisor, or a retry. One call starts one agent.
|
||||
|
||||
Reference in New Issue
Block a user