Localize the renderer UI into Chinese behind the language setting
This commit is contained in:
1 parent
d404e1e72a
commit
3e4c7c81a2
58 files changed
+1176
-597
No files matched your search
@@ -6,6 +6,8 @@ export interface CreateWindowOptions {
|
||||
preloadPath: string;
|
||||
rendererDist: string;
|
||||
devServerUrl?: string;
|
||||
/** UI language for renderer strings; injected synchronously via argv. */
|
||||
language: string;
|
||||
}
|
||||
|
||||
export function createMainWindow(options: CreateWindowOptions): BrowserWindow {
|
||||
@@ -27,6 +29,7 @@ export function createMainWindow(options: CreateWindowOptions): BrowserWindow {
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
additionalArguments: [`--app-language=${options.language}`],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+3
-1
@@ -23,7 +23,8 @@ if (!app.requestSingleInstanceLock()) {
|
||||
// strings, date formatting) is only settable pre-ready, and it follows the
|
||||
// stored language preference instead of the OS locale.
|
||||
const connection = new DatabaseConnection(join(profileDir, "agentcompany.db"));
|
||||
app.commandLine.appendSwitch("lang", storedLanguage(connection.db) === "zh" ? "zh-CN" : "en-US");
|
||||
const language = storedLanguage(connection.db);
|
||||
app.commandLine.appendSwitch("lang", language === "zh" ? "zh-CN" : "en-US");
|
||||
|
||||
app.whenReady().then(() => {
|
||||
connection.transaction(() => recoverInterruptedWork(connection.db));
|
||||
@@ -31,6 +32,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
preloadPath: join(__dirname, "../preload/index.js"),
|
||||
rendererDist: join(__dirname, "../renderer"),
|
||||
devServerUrl: process.env.ELECTRON_RENDERER_URL,
|
||||
language,
|
||||
});
|
||||
|
||||
registerIpcHandlers({
|
||||
|
||||
@@ -94,6 +94,7 @@ const api: DesktopApi = {
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.rendererEvent, listener);
|
||||
},
|
||||
platform: process.platform as DesktopApi["platform"],
|
||||
language: (process.argv.find((arg) => arg.startsWith("--app-language="))?.slice("--app-language=".length) === "zh" ? "zh" : "en") as DesktopApi["language"],
|
||||
windowMinimize: () => invoke<void>(IPC_CHANNELS.windowMinimize),
|
||||
windowMaximize: () => invoke<void>(IPC_CHANNELS.windowMaximize),
|
||||
windowClose: () => invoke<void>(IPC_CHANNELS.windowClose),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
import { ProviderStep } from "./OnboardingProviderStep";
|
||||
import { ConnectorsStep } from "./OnboardingConnectorsStep";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
type Step = "welcome" | "provider" | "company" | "mode" | "mission" | "connectors";
|
||||
|
||||
@@ -26,12 +27,12 @@ export interface Draft {
|
||||
}
|
||||
|
||||
const STEPS: Array<{ id: Step; label: string }> = [
|
||||
{ id: "welcome", label: "Start" },
|
||||
{ id: "provider", label: "Provider" },
|
||||
{ id: "company", label: "Company" },
|
||||
{ id: "mode", label: "Mode" },
|
||||
{ id: "mission", label: "Goal" },
|
||||
{ id: "connectors", label: "Connectors" },
|
||||
{ id: "welcome", label: t("Start") },
|
||||
{ id: "provider", label: t("Provider") },
|
||||
{ id: "company", label: t("Company") },
|
||||
{ id: "mode", label: t("Mode") },
|
||||
{ id: "mission", label: t("Goal") },
|
||||
{ id: "connectors", label: t("Connectors") },
|
||||
];
|
||||
|
||||
export function Onboarding({
|
||||
@@ -133,9 +134,9 @@ export function Onboarding({
|
||||
<header className="onboarding-titlebar">
|
||||
{!isMac && (
|
||||
<span className="window-controls">
|
||||
<IconButton icon="minus" label="Minimize" onClick={() => void window.agentCompany.windowMinimize()} />
|
||||
<IconButton icon="grid" label="Maximize" onClick={() => void window.agentCompany.windowMaximize()} />
|
||||
<IconButton icon="x" label="Close" onClick={() => void window.agentCompany.windowClose()} />
|
||||
<IconButton icon="minus" label={t("Minimize")} onClick={() => void window.agentCompany.windowMinimize()} />
|
||||
<IconButton icon="grid" label={t("Maximize")} onClick={() => void window.agentCompany.windowMaximize()} />
|
||||
<IconButton icon="x" label={t("Close")} onClick={() => void window.agentCompany.windowClose()} />
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
@@ -147,13 +148,13 @@ export function Onboarding({
|
||||
<div style={{ fontWeight: 540 }}>AgentCompany</div>
|
||||
{onCancel && step !== "connectors" && (
|
||||
<button type="button" className="button ghost" style={{ marginLeft: "auto" }} onClick={onCancel}>
|
||||
Cancel
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="page-h1" style={{ fontSize: 24, marginTop: 32, letterSpacing: "-0.02em" }}>Start your AI company.</h1>
|
||||
<h1 className="page-h1" style={{ fontSize: 24, marginTop: 32, letterSpacing: "-0.02em" }}>{t("Start your AI company.")}</h1>
|
||||
<p className="page-sub" style={{ maxWidth: 460, fontSize: 14, lineHeight: 1.55 }}>
|
||||
Stand up the workspace, bind the provider, and queue the first operating loop.
|
||||
{t("Stand up the workspace, bind the provider, and queue the first operating loop.")}
|
||||
</p>
|
||||
<div className="onboarding-steps">
|
||||
{STEPS.map((entry) => (
|
||||
@@ -162,14 +163,14 @@ export function Onboarding({
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ maxWidth: 460 }}>
|
||||
<div className="card-label" style={{ marginBottom: 10 }}>Runtime selected</div>
|
||||
<div className="card-label" style={{ marginBottom: 10 }}>{t("Runtime selected")}</div>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<Icon name="bot" size={18} />
|
||||
<strong style={{ fontSize: 14 }}>{selectedProvider.label}</strong>
|
||||
{draft.providerModel && <Pill tone="violet">{draft.providerModel}</Pill>}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: "var(--text-3)" }}>
|
||||
We never embed the key in the build. Credentials encrypt locally via OS-protected storage.
|
||||
{t("We never embed the key in the build. Credentials encrypt locally via OS-protected storage.")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -233,19 +234,18 @@ interface StepProps {
|
||||
function WelcomeStep({ onNext }: { onNext: () => void }) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Welcome</h2>
|
||||
<h2 className="page-h1">{t("Welcome")}</h2>
|
||||
<p style={{ color: "var(--text-2)", fontSize: 14, lineHeight: 1.55 }}>
|
||||
AgentCompany sets up your operating loop in six steps. Bring an API key, pick a runtime
|
||||
for the founding CEO, and we'll wire up the first goal, project, and task.
|
||||
{t("AgentCompany sets up your operating loop in six steps. Bring an API key, pick a runtime for the founding CEO, and we'll wire up the first goal, project, and task.")}
|
||||
</p>
|
||||
<ul style={{ paddingLeft: 20, color: "var(--text-2)", lineHeight: 1.8, margin: 0, fontSize: 14 }}>
|
||||
<li>Connect one model provider (OpenAI, Anthropic, xAI, or Groq).</li>
|
||||
<li>Name the company and the human board chair.</li>
|
||||
<li>Pick execution mode and bind the workspace path.</li>
|
||||
<li>State the first goal — we kick off the rest.</li>
|
||||
<li>{t("Connect one model provider (OpenAI, Anthropic, xAI, or Groq).")}</li>
|
||||
<li>{t("Name the company and the human board chair.")}</li>
|
||||
<li>{t("Pick execution mode and bind the workspace path.")}</li>
|
||||
<li>{t("State the first goal — we kick off the rest.")}</li>
|
||||
</ul>
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button tone="primary" onClick={onNext}>Get started</Button>
|
||||
<Button tone="primary" onClick={onNext}>{t("Get started")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -259,20 +259,20 @@ function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||
};
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Company</h2>
|
||||
<Field label="Company name">
|
||||
<h2 className="page-h1">{t("Company")}</h2>
|
||||
<Field label={t("Company name")}>
|
||||
<input className="input" value={draft.companyName} onChange={(event) => onChange({ companyName: event.target.value })} autoFocus required />
|
||||
</Field>
|
||||
<Field label="Your name">
|
||||
<Field label={t("Your name")}>
|
||||
<input className="input" value={draft.employeeName} onChange={(event) => onChange({ employeeName: event.target.value })} required />
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
<Field label={t("Description")}>
|
||||
<textarea className="input" value={draft.companyDescription} onChange={(event) => onChange({ companyDescription: event.target.value })} required />
|
||||
</Field>
|
||||
<Field label="Workspace path (optional)">
|
||||
<Field label={t("Workspace path (optional)")}>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<input className="input" value={draft.workspacePath} onChange={(event) => onChange({ workspacePath: event.target.value })} />
|
||||
<Button tone="ghost" onClick={chooseWorkspace}><Icon name="folder" size={16} /> Choose</Button>
|
||||
<Button tone="ghost" onClick={chooseWorkspace}><Icon name="folder" size={16} /> {t("Choose")}</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Actions onBack={onBack} onNext={onNext} />
|
||||
@@ -283,17 +283,17 @@ function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||
function ModeStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Mode</h2>
|
||||
<p style={{ color: "var(--text-3)", fontSize: 14 }}>How autonomous should the CEO be?</p>
|
||||
<h2 className="page-h1">{t("Mode")}</h2>
|
||||
<p style={{ color: "var(--text-3)", fontSize: 14 }}>{t("How autonomous should the CEO be?")}</p>
|
||||
<ModeCard
|
||||
title="AI CEO"
|
||||
description="Agents auto-approve hires up to the budget envelope and ship work that clears policy."
|
||||
title={t("AI CEO")}
|
||||
description={t("Agents auto-approve hires up to the budget envelope and ship work that clears policy.")}
|
||||
active={draft.executionMode === "ai_ceo"}
|
||||
onClick={() => onChange({ executionMode: "ai_ceo" })}
|
||||
/>
|
||||
<ModeCard
|
||||
title="AI Co-Founder"
|
||||
description="Hires need your stamp; deliverables route through review before shipping."
|
||||
title={t("AI Co-Founder")}
|
||||
description={t("Hires need your stamp; deliverables route through review before shipping.")}
|
||||
active={draft.executionMode === "ai_cofounder"}
|
||||
onClick={() => onChange({ executionMode: "ai_cofounder" })}
|
||||
/>
|
||||
@@ -310,16 +310,16 @@ function MissionStep({
|
||||
}: Omit<StepProps, "onNext"> & { submitting: boolean }) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Goal</h2>
|
||||
<Field label="Goal">
|
||||
<h2 className="page-h1">{t("Goal")}</h2>
|
||||
<Field label={t("Goal")}>
|
||||
<input className="input" value={draft.goalTitle} onChange={(event) => onChange({ goalTitle: event.target.value })} required />
|
||||
</Field>
|
||||
<Field label="Acceptance criteria">
|
||||
<Field label={t("Acceptance criteria")}>
|
||||
<textarea className="input" value={draft.goalDescription} onChange={(event) => onChange({ goalDescription: event.target.value })} required />
|
||||
</Field>
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button onClick={onBack}>Back</Button>
|
||||
<Button tone="primary" type="submit" disabled={submitting}>{submitting ? "Launching…" : "Launch"}</Button>
|
||||
<Button onClick={onBack}>{t("Back")}</Button>
|
||||
<Button tone="primary" type="submit" disabled={submitting}>{submitting ? t("Launching…") : t("Launch")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -347,8 +347,8 @@ function ModeCard({
|
||||
function Actions({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
|
||||
return (
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button onClick={onBack}>Back</Button>
|
||||
<Button tone="primary" onClick={onNext}>Continue</Button>
|
||||
<Button onClick={onBack}>{t("Back")}</Button>
|
||||
<Button tone="primary" onClick={onNext}>{t("Continue")}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -360,24 +360,24 @@ function requireProvider(providers: ProviderCatalogEntry[], key: string): Provid
|
||||
}
|
||||
|
||||
function validateProvider(draft: Draft, providers: ProviderCatalogEntry[]): string | null {
|
||||
if (!draft.providerKey) return "Pick a provider.";
|
||||
if (!draft.providerKey) return t("Pick a provider.");
|
||||
const selected = providers.find((entry) => entry.key === draft.providerKey);
|
||||
if (!selected) return "Provider is no longer available.";
|
||||
if (selected.customEndpoint && !draft.providerBaseUrl.trim()) return "Base URL is required.";
|
||||
if (!selected) return t("Provider is no longer available.");
|
||||
if (selected.customEndpoint && !draft.providerBaseUrl.trim()) return t("Base URL is required.");
|
||||
if (!draft.providerSecret.trim()) {
|
||||
return selected.authType === "api_key" || selected.customEndpoint ? "API key is required." : "Access token is required.";
|
||||
return selected.authType === "api_key" || selected.customEndpoint ? t("API key is required.") : t("Access token is required.");
|
||||
}
|
||||
if (!draft.providerModel.trim()) return "Model is required.";
|
||||
if (!draft.providerModel.trim()) return t("Model is required.");
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCompany(draft: Draft): string | null {
|
||||
if (!draft.companyName.trim()) return "Company name is required.";
|
||||
if (!draft.employeeName.trim()) return "Your name is required.";
|
||||
if (!draft.companyDescription.trim()) return "Description is required.";
|
||||
if (!draft.companyName.trim()) return t("Company name is required.");
|
||||
if (!draft.employeeName.trim()) return t("Your name is required.");
|
||||
if (!draft.companyDescription.trim()) return t("Description is required.");
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateAll(draft: Draft, providers: ProviderCatalogEntry[]): string | null {
|
||||
return validateProvider(draft, providers) ?? validateCompany(draft) ?? (!draft.goalTitle.trim() ? "Goal is required." : null);
|
||||
return validateProvider(draft, providers) ?? validateCompany(draft) ?? (!draft.goalTitle.trim() ? t("Goal is required.") : null);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Button } from "../shared/ui";
|
||||
import { ToolkitCatalog } from "../pages/connectors/ToolkitCatalog";
|
||||
import { useConnectorCatalog } from "../pages/connectors/useConnectorCatalog";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
/**
|
||||
* The post-launch onboarding step: the company exists, so connections can be
|
||||
@@ -34,7 +35,7 @@ export function ConnectorsStep({
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Connectors</h2>
|
||||
<h2 className="page-h1">{t("Connectors")}</h2>
|
||||
{catalog.phase === "error" && (
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<span
|
||||
@@ -50,7 +51,7 @@ export function ConnectorsStep({
|
||||
>
|
||||
{catalog.message}
|
||||
</span>
|
||||
<Button size="small" onClick={retry}>Retry</Button>
|
||||
<Button size="small" onClick={retry}>{t("Retry")}</Button>
|
||||
</div>
|
||||
)}
|
||||
{catalog.phase === "ready" && (
|
||||
@@ -77,7 +78,7 @@ export function ConnectorsStep({
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button tone="primary" onClick={onFinish}>Finish</Button>
|
||||
<Button tone="primary" onClick={onFinish}>{t("Finish")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ProviderCatalogEntry } from "../../../shared/domain";
|
||||
import { ProviderOAuthConnect } from "../shared/ProviderOAuthConnect";
|
||||
import { Button, Field, Pill } from "../shared/ui";
|
||||
import type { Draft } from "./Onboarding";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
export function ProviderStep({
|
||||
providers,
|
||||
@@ -22,9 +23,9 @@ export function ProviderStep({
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Provider</h2>
|
||||
<h2 className="page-h1">{t("Provider")}</h2>
|
||||
<p style={{ color: "var(--text-3)", fontSize: 14 }}>
|
||||
Pick the runtime for the founding CEO. You can attach more providers in Settings later.
|
||||
{t("Pick the runtime for the founding CEO. You can attach more providers in Settings later.")}
|
||||
</p>
|
||||
<div className="option-grid">
|
||||
{providers.map((provider) => (
|
||||
@@ -123,14 +124,14 @@ function ProviderCredentialFields({
|
||||
/>
|
||||
) : isCustom ? (
|
||||
<>
|
||||
<Field label="Base URL">
|
||||
<Field label={t("Base URL")}>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.providerBaseUrl}
|
||||
onChange={(event) => onChange({ providerBaseUrl: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API key">
|
||||
<Field label={t("API key")}>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
@@ -141,7 +142,7 @@ function ProviderCredentialFields({
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label="API key">
|
||||
<Field label={t("API key")}>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
@@ -153,12 +154,12 @@ function ProviderCredentialFields({
|
||||
)}
|
||||
{!isOAuthLike && live && (
|
||||
<Button tone="ghost" onClick={loadTypedModels} disabled={!hasCredential || loading}>
|
||||
{loading ? "Getting models..." : fetched ? "Reload models" : "Get models"}
|
||||
{loading ? t("Getting models...") : fetched ? t("Reload models") : t("Get models")}
|
||||
</Button>
|
||||
)}
|
||||
{loadError && <div style={{ color: "var(--red)", fontSize: 12 }}>{loadError}</div>}
|
||||
{(hasCredential || isCustom) && (
|
||||
<Field label="Model">
|
||||
<Field label={t("Model")}>
|
||||
{fetched || showCuratedList ? (
|
||||
<select
|
||||
className="input"
|
||||
@@ -185,16 +186,16 @@ function ProviderCredentialFields({
|
||||
function Actions({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
|
||||
return (
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button onClick={onBack}>Back</Button>
|
||||
<Button tone="primary" onClick={onNext}>Continue</Button>
|
||||
<Button onClick={onBack}>{t("Back")}</Button>
|
||||
<Button tone="primary" onClick={onNext}>{t("Continue")}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function authBadge(authType: string): string {
|
||||
if (authType === "api_key") return "API key";
|
||||
if (authType === "oauth_pkce") return "OAuth";
|
||||
if (authType === "account_subscription") return "Account";
|
||||
if (authType === "cloud") return "Cloud";
|
||||
if (authType === "api_key") return t("API key");
|
||||
if (authType === "oauth_pkce") return t("OAuth");
|
||||
if (authType === "account_subscription") return t("Account");
|
||||
if (authType === "cloud") return t("Cloud");
|
||||
return authType;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { zh } from "./zh";
|
||||
|
||||
/**
|
||||
* UI strings ship in English and the English source string is the key —
|
||||
* the Chinese dictionary maps it to its translation, and a missing entry
|
||||
* renders the English source. The language is fixed per process (changing
|
||||
* it relaunches the app), injected synchronously through preload argv so
|
||||
* the first paint is already in the right language.
|
||||
*/
|
||||
const language: "en" | "zh" =
|
||||
typeof window !== "undefined" && window.agentCompany ? window.agentCompany.language : "en";
|
||||
|
||||
export function t(source: string, vars?: Record<string, string | number>): string {
|
||||
const template = language === "zh" ? zh[source] ?? source : source;
|
||||
if (!vars) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_match: string, name: string) => String(vars[name] ?? ""));
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Chinese UI strings, keyed by the English source string. Keep {token}
|
||||
* placeholders intact — t() substitutes them after lookup. The i18n coverage
|
||||
* test fails when a t() call site has no entry here.
|
||||
*/
|
||||
export const zh: Record<string, string> = {
|
||||
"(edited)": "(已编辑)",
|
||||
"AI CEO": "AI CEO",
|
||||
"AI Co-Founder": "AI 联合创始人",
|
||||
"AI → AI": "AI → AI",
|
||||
"API key": "API 密钥",
|
||||
"API key is required.": "请填写 API 密钥。",
|
||||
"Acceptance criteria": "验收标准",
|
||||
"Access": "权限",
|
||||
"Access & activity": "权限与活动",
|
||||
"Access token is required.": "请填写访问令牌。",
|
||||
"Account": "账户",
|
||||
"Action": "操作",
|
||||
"Actions": "操作",
|
||||
"Actions for {name}": "对 {name} 的操作",
|
||||
"Active": "活跃",
|
||||
"Activity": "活动",
|
||||
"Ad-hoc": "临时",
|
||||
"Add": "添加",
|
||||
"Add task": "添加任务",
|
||||
"Add workspace": "添加工作区",
|
||||
"Advance": "推进",
|
||||
"Advanced": "高级",
|
||||
"Agent": "智能体",
|
||||
"Agent could not be reached": "无法连接到智能体",
|
||||
"Agent runs": "智能体运行",
|
||||
"Agent workload": "智能体工作量",
|
||||
"Agent {name}": "智能体 {name}",
|
||||
"AgentCompany sets up your operating loop in six steps. Bring an API key, pick a runtime for the founding CEO, and we'll wire up the first goal, project, and task.":
|
||||
"AgentCompany 用六步搭建你的运营循环:带上 API 密钥,为创始 CEO 选择运行时,我们会接好第一个目标、项目和任务。",
|
||||
"Agents": "智能体",
|
||||
"Agents auto-approve hires up to the budget envelope and ship work that clears policy.": "智能体在预算范围内自动批准招聘,并交付通过策略检查的工作。",
|
||||
"Agents only": "仅智能体",
|
||||
"All": "全部",
|
||||
"All agents, work, chats, and files are permanently removed.": "所有智能体、工作、聊天和文件将被永久删除。",
|
||||
"All assignees": "全部负责人",
|
||||
"All files": "全部文件",
|
||||
"All priorities": "全部优先级",
|
||||
"All risk": "全部风险",
|
||||
"All runs": "全部运行",
|
||||
"Allow": "允许",
|
||||
"Appearance": "外观",
|
||||
"Approval": "审批",
|
||||
"Approval needed": "需要审批",
|
||||
"Approvals": "审批",
|
||||
"Approve": "批准",
|
||||
"Approve all low risk": "批准全部低风险",
|
||||
"Approved": "已批准",
|
||||
"Approved (7d)": "已批准(7 天)",
|
||||
"Assignee": "负责人",
|
||||
"Attach file": "附加文件",
|
||||
"Auto-approve hires": "自动批准招聘",
|
||||
"Available": "空闲",
|
||||
"Back": "返回",
|
||||
"Background": "后台",
|
||||
"Backlog": "待办",
|
||||
"Base URL": "基础 URL",
|
||||
"Base URL is required.": "请填写基础 URL。",
|
||||
"Billing": "用量账单",
|
||||
"Blocked": "受阻",
|
||||
"Board": "看板",
|
||||
"Browse": "浏览",
|
||||
"Budget": "预算",
|
||||
"Budget cap": "预算上限",
|
||||
"Budget utilization": "预算使用率",
|
||||
"Busy": "忙碌",
|
||||
"By agent": "按智能体",
|
||||
"CEO OFFICE": "CEO 办公室",
|
||||
"Cancel": "取消",
|
||||
"Catalog": "目录",
|
||||
"Check": "检查",
|
||||
"Choose": "选择",
|
||||
"Clean": "正常",
|
||||
"Close": "关闭",
|
||||
"Close agent details": "关闭智能体详情",
|
||||
"Cloud": "云端",
|
||||
"Commands": "命令",
|
||||
"Company": "公司",
|
||||
"Company name": "公司名称",
|
||||
"Company name is required.": "请填写公司名称。",
|
||||
"Completed": "已完成",
|
||||
"Connect": "连接",
|
||||
"Connect a provider to run": "连接模型服务后可运行",
|
||||
"Connect one model provider (OpenAI, Anthropic, xAI, or Groq).": "连接一个模型服务商(OpenAI、Anthropic、xAI 或 Groq)。",
|
||||
"Connect provider": "连接服务商",
|
||||
"Connect {label}": "连接 {label}",
|
||||
"Connected": "已连接",
|
||||
"Connectors": "连接器",
|
||||
"Context": "上下文",
|
||||
"Continue": "继续",
|
||||
"Cost by agent": "按智能体成本",
|
||||
"Create": "创建",
|
||||
"Created": "创建时间",
|
||||
"Credential": "凭证",
|
||||
"Dark": "深色",
|
||||
"Data policy": "数据策略",
|
||||
"Decision notes": "决策备注",
|
||||
"Decline": "拒绝",
|
||||
"Declined": "已拒绝",
|
||||
"Declined (7d)": "已拒绝(7 天)",
|
||||
"Default model": "默认模型",
|
||||
"Delete": "删除",
|
||||
"Delete message": "删除消息",
|
||||
"Delete organization": "删除组织",
|
||||
"Delete {name} connection": "删除 {name} 连接",
|
||||
"Delete “{name}”?": "删除“{name}”?",
|
||||
"Department": "部门",
|
||||
"Deployment / model id": "部署 / 模型 ID",
|
||||
"Description": "描述",
|
||||
"Description is required.": "请填写描述。",
|
||||
"Direct": "私信",
|
||||
"Director": "总监",
|
||||
"Directory path": "目录路径",
|
||||
"Division": "事业部",
|
||||
"Done": "已完成",
|
||||
"Duration": "耗时",
|
||||
"Edit": "编辑",
|
||||
"Edit message": "编辑消息",
|
||||
"Edit task": "编辑任务",
|
||||
"Edit {name}": "编辑 {name}",
|
||||
"Employee": "成员",
|
||||
"Employees": "成员",
|
||||
"Error": "错误",
|
||||
"Errors": "错误",
|
||||
"Events": "事件",
|
||||
"Executive": "高管",
|
||||
"Failed": "失败",
|
||||
"Failures (30d)": "失败(30 天)",
|
||||
"File": "文件",
|
||||
"Files": "文件",
|
||||
"Filter": "筛选",
|
||||
"Filter files": "筛选文件",
|
||||
"Filter risk": "筛选风险",
|
||||
"Filter tasks": "筛选任务",
|
||||
"Finish": "完成",
|
||||
"Foreground": "前台",
|
||||
"General": "通用",
|
||||
"Get models": "获取模型",
|
||||
"Get started": "开始使用",
|
||||
"Getting models...": "正在获取模型…",
|
||||
"Goal": "目标",
|
||||
"Goal is required.": "请填写目标。",
|
||||
"Good afternoon": "下午好",
|
||||
"Good evening": "晚上好",
|
||||
"Good morning": "早上好",
|
||||
"Grid": "网格",
|
||||
"Healthy": "健康",
|
||||
"Held by": "持有者",
|
||||
"High": "高",
|
||||
"High risk": "高风险",
|
||||
"Hire": "招聘",
|
||||
"Hire agent": "招聘智能体",
|
||||
"Hires need your stamp; deliverables route through review before shipping.": "招聘需要你签字确认;交付物先经审核再发布。",
|
||||
"Hires require approval": "招聘需审批",
|
||||
"Hiring pipeline": "招聘进展",
|
||||
"Home": "首页",
|
||||
"How autonomous should the CEO be?": "CEO 的自主程度?",
|
||||
"Humans only": "仅人类",
|
||||
"Idle": "空闲",
|
||||
"Import": "导入",
|
||||
"Import skill": "导入技能",
|
||||
"In progress": "进行中",
|
||||
"In review": "审核中",
|
||||
"Input": "输入",
|
||||
"Input chars": "输入字符",
|
||||
"Instructions": "指令",
|
||||
"Language": "语言",
|
||||
"Launch": "启动",
|
||||
"Launching…": "启动中…",
|
||||
"Lead": "负责人",
|
||||
"Lead agent": "负责智能体",
|
||||
"Library": "库",
|
||||
"Light": "浅色",
|
||||
"List": "列表",
|
||||
"Loading models...": "正在加载模型…",
|
||||
"Local path": "本地路径",
|
||||
"Low": "低",
|
||||
"Low risk": "低风险",
|
||||
"MEETING ROOM": "会议室",
|
||||
"Manage": "管理",
|
||||
"Manager": "经理",
|
||||
"Maximize": "最大化",
|
||||
"Median time": "中位耗时",
|
||||
"Medium": "中",
|
||||
"Medium risk": "中风险",
|
||||
"Member": "成员",
|
||||
"Message": "消息",
|
||||
"Message deleted": "消息已删除",
|
||||
"Messages": "消息",
|
||||
"Minimize": "最小化",
|
||||
"Mission": "使命",
|
||||
"Mode": "模式",
|
||||
"Model": "模型",
|
||||
"Model is required.": "请填写模型。",
|
||||
"Modified": "修改时间",
|
||||
"Monthly allocation": "每月配额",
|
||||
"Monthly budget": "每月预算",
|
||||
"Monthly limit": "每月上限",
|
||||
"Name": "名称",
|
||||
"Name the company and the human board chair.": "命名公司与人类董事会主席。",
|
||||
"Never run": "从未运行",
|
||||
"New goal": "新目标",
|
||||
"New organization": "新组织",
|
||||
"New project": "新项目",
|
||||
"New space": "新空间",
|
||||
"New task": "新任务",
|
||||
"Next month": "下个月",
|
||||
"No budget cap set": "未设置预算上限",
|
||||
"No events": "暂无事件",
|
||||
"No messages yet": "还没有消息",
|
||||
"No providers connected": "未连接任何服务商",
|
||||
"No runs": "暂无运行",
|
||||
"No runs yet": "还没有运行记录",
|
||||
"No subtasks": "暂无子任务",
|
||||
"No tests": "无测试",
|
||||
"None": "无",
|
||||
"Normal": "普通",
|
||||
"OAuth": "OAuth",
|
||||
"Office": "办公室",
|
||||
"OnCall": "值班",
|
||||
"Open": "打开",
|
||||
"Open chat": "打开聊天",
|
||||
"Open {name}": "打开 {name}",
|
||||
"Open {title}": "打开 {title}",
|
||||
"Opening…": "打开中…",
|
||||
"Org": "组织",
|
||||
"Organization": "组织",
|
||||
"Organizations": "组织",
|
||||
"Output": "输出",
|
||||
"Output chars": "输出字符",
|
||||
"Overview": "概览",
|
||||
"Owner": "负责人",
|
||||
"Owner agent": "负责智能体",
|
||||
"Parent task": "父任务",
|
||||
"Pending": "待处理",
|
||||
"Pending approval": "待审批",
|
||||
"Pick a provider.": "请选择服务商。",
|
||||
"Pick execution mode and bind the workspace path.": "选择执行模式并绑定工作区路径。",
|
||||
"Pick the runtime for the founding CEO. You can attach more providers in Settings later.": "为创始 CEO 选择运行时;之后可在设置中接入更多服务商。",
|
||||
"Pin": "置顶",
|
||||
"Pinned": "已置顶",
|
||||
"Pod": "小组",
|
||||
"Post": "帖子",
|
||||
"Previous month": "上个月",
|
||||
"Primary": "主要",
|
||||
"Priority": "优先级",
|
||||
"Project": "项目",
|
||||
"Project / resource": "项目 / 资源",
|
||||
"Projects": "项目",
|
||||
"Prompt": "提示词",
|
||||
"Provider": "服务商",
|
||||
"Provider activity (30d)": "服务商活动(30 天)",
|
||||
"Provider is no longer available.": "该服务商已不可用。",
|
||||
"Provider request": "服务商请求",
|
||||
"Providers": "服务商",
|
||||
"Quarter goal": "季度目标",
|
||||
"Queued": "排队中",
|
||||
"RESTROOM": "洗手间",
|
||||
"Recent": "最近",
|
||||
"Recent messages": "最近消息",
|
||||
"Recently decided": "最近已决",
|
||||
"Reconnect": "重新连接",
|
||||
"Refresh models": "刷新模型",
|
||||
"Region": "区域",
|
||||
"Rejected": "已驳回",
|
||||
"Reload models": "重新加载模型",
|
||||
"Remove attachment": "移除附件",
|
||||
"Reporting lines": "汇报关系",
|
||||
"Request ID": "请求 ID",
|
||||
"Request instructions": "请求指令",
|
||||
"Request prompt": "请求提示词",
|
||||
"Request provider": "请求服务商",
|
||||
"Request workspace": "请求工作区",
|
||||
"Requested": "请求时间",
|
||||
"Requested by": "请求人",
|
||||
"Requested by {requester}": "由 {requester} 请求",
|
||||
"Requests": "请求",
|
||||
"Requests (30d)": "请求(30 天)",
|
||||
"Requires workspace access": "需要工作区访问权限",
|
||||
"Reset zoom": "重置缩放",
|
||||
"Response": "响应",
|
||||
"Restricted": "受限",
|
||||
"Retry": "重试",
|
||||
"Reviews deliverables": "交付物需审核",
|
||||
"Revoke": "撤销",
|
||||
"Risk": "风险",
|
||||
"Role": "角色",
|
||||
"Roster": "名册",
|
||||
"Run": "运行",
|
||||
"Run activity": "运行活动",
|
||||
"Run tests": "运行测试",
|
||||
"Run {title}": "运行 {title}",
|
||||
"Running": "运行中",
|
||||
"Runs": "运行",
|
||||
"Runtime policy": "运行策略",
|
||||
"Runtime selected": "已选运行时",
|
||||
"SNACK BAR": "茶水间",
|
||||
"Save": "保存",
|
||||
"Save {label} policy": "保存 {label} 策略",
|
||||
"Scan status": "扫描状态",
|
||||
"Scope": "范围",
|
||||
"Search": "搜索",
|
||||
"Search connectors": "搜索连接器",
|
||||
"Search filter": "搜索筛选",
|
||||
"Search messages": "搜索消息",
|
||||
"Search query": "搜索关键词",
|
||||
"Search runs": "搜索运行",
|
||||
"Select a run": "选择一次运行",
|
||||
"Send": "发送",
|
||||
"Seniority": "职级",
|
||||
"Set the first goal for {organizationName}.": "为 {organizationName} 设定第一个目标。",
|
||||
"Settings": "设置",
|
||||
"Ships without review": "无需审核直接发布",
|
||||
"Show earlier messages": "显示更早的消息",
|
||||
"Size": "大小",
|
||||
"Skill": "技能",
|
||||
"Skills": "技能",
|
||||
"Social": "社交",
|
||||
"Source": "来源",
|
||||
"Space": "空间",
|
||||
"Spaces": "空间",
|
||||
"Staff": "员工",
|
||||
"Stand up the workspace, bind the provider, and queue the first operating loop.": "搭建工作区、绑定模型服务,并排入第一个运营循环。",
|
||||
"Standard retention": "标准保留",
|
||||
"Start": "开始",
|
||||
"Start your AI company.": "开启你的 AI 公司。",
|
||||
"Started": "开始时间",
|
||||
"State": "状态",
|
||||
"State the first goal — we kick off the rest.": "写下第一个目标,其余交给我们启动。",
|
||||
"Status": "状态",
|
||||
"Subtasks": "子任务",
|
||||
"Success rate": "成功率",
|
||||
"Switch to this org": "切换到该组织",
|
||||
"System": "跟随系统",
|
||||
"Task": "任务",
|
||||
"Team": "团队",
|
||||
"Tests": "测试",
|
||||
"Theme": "主题",
|
||||
"Thinking": "思考中",
|
||||
"Timeline": "时间线",
|
||||
"Title": "标题",
|
||||
"Today": "今天",
|
||||
"Token usage": "Token 用量",
|
||||
"Token usage (30d)": "Token 用量(30 天)",
|
||||
"Topic": "话题",
|
||||
"Total": "合计",
|
||||
"Trigger": "触发",
|
||||
"Type": "类型",
|
||||
"Unassigned": "未分配",
|
||||
"Unchecked": "未检查",
|
||||
"Unknown": "未知",
|
||||
"Unlinked tasks": "未关联任务",
|
||||
"Unpin": "取消置顶",
|
||||
"Upload": "上传",
|
||||
"Usage": "用量",
|
||||
"Version": "版本",
|
||||
"Version (semver)": "版本(semver)",
|
||||
"Visibility": "可见性",
|
||||
"Waiting on you": "等你处理",
|
||||
"We never embed the key in the build. Credentials encrypt locally via OS-protected storage.": "密钥绝不打包进构建。凭证经操作系统保护的存储在本地加密。",
|
||||
"Welcome": "欢迎",
|
||||
"Work": "工作",
|
||||
"Workspace": "工作区",
|
||||
"Workspace path (optional)": "工作区路径(可选)",
|
||||
"Workspaces": "工作区",
|
||||
"Yesterday": "昨天",
|
||||
"You're all caught up": "都处理完了",
|
||||
"Your name": "你的名字",
|
||||
"Your name is required.": "请填写你的名字。",
|
||||
"Zero data retention": "零数据保留",
|
||||
"Zoom in": "放大",
|
||||
"Zoom out": "缩小",
|
||||
"agent": "智能体",
|
||||
"ahead": "超前",
|
||||
"allocated": "已分配",
|
||||
"backlog": "待办",
|
||||
"behind": "落后",
|
||||
"completed": "已完成",
|
||||
"done": "已完成",
|
||||
"failed": "失败",
|
||||
"high": "高",
|
||||
"high risk": "高风险",
|
||||
"in progress": "进行中",
|
||||
"low": "低",
|
||||
"no work yet": "暂无工作",
|
||||
"normal": "普通",
|
||||
"on track": "正常推进",
|
||||
"queued": "排队中",
|
||||
"review": "审核中",
|
||||
"running": "运行中",
|
||||
"scoped": "限定范围",
|
||||
"workspace": "工作区",
|
||||
"{count} action": "{count} 个操作",
|
||||
"{count} actions": "{count} 个操作",
|
||||
"{count} agent": "{count} 个智能体",
|
||||
"{count} agents": "{count} 个智能体",
|
||||
"{count} chars": "{count} 字符",
|
||||
"{done} / {total} tasks": "{done} / {total} 个任务",
|
||||
"{kind} · {risk} risk · from {requester}": "{kind} · {risk}风险 · 来自 {requester}",
|
||||
"{label} background execution": "{label} 后台执行",
|
||||
"{label} data policy": "{label} 数据策略",
|
||||
"{label} monthly request limit": "{label} 每月请求上限",
|
||||
"{label} workspace scope": "{label} 工作区范围",
|
||||
"{name} — switch organization": "{name} — 切换组织",
|
||||
"{n} agent": "{n} 个智能体",
|
||||
"{n} agent on deck": "{n} 个智能体在岗",
|
||||
"{n} agents": "{n} 个智能体",
|
||||
"{n} agents on deck": "{n} 个智能体在岗",
|
||||
"{n} assignee": "{n} 位负责人",
|
||||
"{n} assignees": "{n} 位负责人",
|
||||
"{n} file": "{n} 个文件",
|
||||
"{n} files": "{n} 个文件",
|
||||
"{n} human": "{n} 位人类成员",
|
||||
"{n} humans": "{n} 位人类成员",
|
||||
"{n} member": "{n} 位成员",
|
||||
"{n} members": "{n} 位成员",
|
||||
"{n} thing waiting on you": "{n} 件事等你处理",
|
||||
"{n} things waiting on you": "{n} 件事等你处理",
|
||||
"{n}d ago": "{n} 天前",
|
||||
"{n}h ago": "{n} 小时前",
|
||||
"{n}m ago": "{n} 分钟前",
|
||||
"{n}w ago": "{n} 周前",
|
||||
"just now": "刚刚",
|
||||
"{n} failed": "{n} 个失败",
|
||||
"{n} open": "{n} 个未完成",
|
||||
"{n} queued": "{n} 个排队中",
|
||||
"{n} running": "{n} 个运行中",
|
||||
"{n} task": "{n} 个任务",
|
||||
"{n} tasks": "{n} 个任务",
|
||||
"{n} this week": "本周 {n} 次",
|
||||
"{ok} ok · {failed} failed": "{ok} 成功 · {failed} 失败",
|
||||
"{passed}/{total} passed": "{passed}/{total} 通过",
|
||||
"{pending} pending · {approved} approved this week": "{pending} 待处理 · 本周已批准 {approved}",
|
||||
"{percent}% of cap": "上限的 {percent}%",
|
||||
};
|
||||
@@ -10,20 +10,21 @@ import type {
|
||||
import { APPROVAL_STATE, RISK_LEVEL } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatRelative, requireActiveEmployee } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
type RiskFilter = "all" | RiskLevel;
|
||||
|
||||
const RISK_FILTERS: ReadonlyArray<{ id: RiskFilter; label: string }> = [
|
||||
{ id: "all", label: "All risk" },
|
||||
{ id: RISK_LEVEL.HIGH, label: "High" },
|
||||
{ id: RISK_LEVEL.MEDIUM, label: "Medium" },
|
||||
{ id: RISK_LEVEL.LOW, label: "Low" },
|
||||
{ id: "all", label: t("All risk") },
|
||||
{ id: RISK_LEVEL.HIGH, label: t("High") },
|
||||
{ id: RISK_LEVEL.MEDIUM, label: t("Medium") },
|
||||
{ id: RISK_LEVEL.LOW, label: t("Low") },
|
||||
];
|
||||
|
||||
const RISK_TONES: Record<RiskLevel, { label: string; color: string }> = {
|
||||
[RISK_LEVEL.HIGH]: { label: "High risk", color: "var(--red)" },
|
||||
[RISK_LEVEL.MEDIUM]: { label: "Medium risk", color: "var(--amber)" },
|
||||
[RISK_LEVEL.LOW]: { label: "Low risk", color: "var(--text-3)" },
|
||||
[RISK_LEVEL.HIGH]: { label: t("High risk"), color: "var(--red)" },
|
||||
[RISK_LEVEL.MEDIUM]: { label: t("Medium risk"), color: "var(--amber)" },
|
||||
[RISK_LEVEL.LOW]: { label: t("Low risk"), color: "var(--text-3)" },
|
||||
};
|
||||
|
||||
export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
||||
@@ -66,21 +67,21 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
||||
<div className="page">
|
||||
<TopbarActions>
|
||||
<RiskMenu filter={filter} onChange={setFilter} />
|
||||
<Button tone="primary" onClick={approveAllLowRisk}>Approve all low risk</Button>
|
||||
<Button tone="primary" onClick={approveAllLowRisk}>{t("Approve all low risk")}</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Approvals</h1>
|
||||
<h1 className="page-h1">{t("Approvals")}</h1>
|
||||
<p className="page-sub">
|
||||
{pending.length} pending · {approved7d} approved this week
|
||||
{t("{pending} pending · {approved} approved this week", { pending: pending.length, approved: approved7d })}
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginBottom: 24 }}>
|
||||
<StatTile label="Pending" value={String(pending.length)} tone="amber" />
|
||||
<StatTile label="Median time" value={formatMinutes(medianTime)} />
|
||||
<StatTile label="Approved (7d)" value={String(approved7d)} />
|
||||
<StatTile label="Declined (7d)" value={String(declined7d)} />
|
||||
<StatTile label={t("Pending")} value={String(pending.length)} tone="amber" />
|
||||
<StatTile label={t("Median time")} value={formatMinutes(medianTime)} />
|
||||
<StatTile label={t("Approved (7d)")} value={String(approved7d)} />
|
||||
<StatTile label={t("Declined (7d)")} value={String(declined7d)} />
|
||||
</div>
|
||||
|
||||
{filteredPending.length > 0 && <div className="section-label" style={{ marginBottom: 10 }}>Pending</div>}
|
||||
{filteredPending.length > 0 && <div className="section-label" style={{ marginBottom: 10 }}>{t("Pending")}</div>}
|
||||
{filteredPending.map((approval) => (
|
||||
<ApprovalRow
|
||||
key={approval.id}
|
||||
@@ -95,7 +96,7 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
{decided.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ margin: "32px 0 10px" }}>Recently decided</div>
|
||||
<div className="section-label" style={{ margin: "32px 0 10px" }}>{t("Recently decided")}</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{decided.slice(0, 24).map((approval, index) => (
|
||||
<DecidedRow
|
||||
@@ -140,7 +141,7 @@ function ApprovalDetailDialog({
|
||||
<div className="modal" role="dialog" aria-label={approval.title} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
||||
<IconButton icon="x" label="Close" onClick={onClose} />
|
||||
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 12, color: tone.color, fontWeight: 540, letterSpacing: "0.02em" }}>{tone.label}</span>
|
||||
@@ -151,23 +152,23 @@ function ApprovalDetailDialog({
|
||||
{approval.details}
|
||||
</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "10px 14px", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text-3)" }}>Requested by</span>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Requested by")}</span>
|
||||
<span>{requester}</span>
|
||||
{approval.sourceEntityType && (<><span style={{ color: "var(--text-3)" }}>Source</span><span>{humanizeKind(approval.sourceEntityType)}</span></>)}
|
||||
<span style={{ color: "var(--text-3)" }}>Requested</span>
|
||||
{approval.sourceEntityType && (<><span style={{ color: "var(--text-3)" }}>{t("Source")}</span><span>{humanizeKind(approval.sourceEntityType)}</span></>)}
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Requested")}</span>
|
||||
<span>{formatRelative(approval.createdAt)}</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="input"
|
||||
aria-label="Decision notes"
|
||||
aria-label={t("Decision notes")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
style={{ marginTop: 14 }}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => onDecide("declined", notes || undefined)}>Decline</Button>
|
||||
<Button tone="primary" onClick={() => onDecide("approved", notes || undefined)}>Approve</Button>
|
||||
<Button onClick={() => onDecide("declined", notes || undefined)}>{t("Decline")}</Button>
|
||||
<Button tone="primary" onClick={() => onDecide("approved", notes || undefined)}>{t("Approve")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,7 +187,7 @@ function RiskMenu({ filter, onChange }: { filter: RiskFilter; onChange: (next: R
|
||||
value={filter}
|
||||
onChange={(event) => onChange(event.target.value as RiskFilter)}
|
||||
style={{ padding: "0 10px" }}
|
||||
aria-label="Filter risk"
|
||||
aria-label={t("Filter risk")}
|
||||
>
|
||||
{RISK_FILTERS.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
|
||||
</select>
|
||||
@@ -251,13 +252,13 @@ function ApprovalRow({
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Avatar name={requester} size="sm" bot={isBot} />
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>Requested by {requester} · {formatRelative(approval.createdAt)}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{t("Requested by {requester}", { requester })} · {formatRelative(approval.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
||||
<Button tone="primary" onClick={onApprove}>Approve</Button>
|
||||
<Button onClick={onEdit}>Edit</Button>
|
||||
<Button tone="ghost" onClick={onDecline}>Decline</Button>
|
||||
<Button tone="primary" onClick={onApprove}>{t("Approve")}</Button>
|
||||
<Button onClick={onEdit}>{t("Edit")}</Button>
|
||||
<Button tone="ghost" onClick={onDecline}>{t("Decline")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -275,7 +276,7 @@ function DecidedRow({
|
||||
last: boolean;
|
||||
}) {
|
||||
const approved = approval.state === APPROVAL_STATE.APPROVED;
|
||||
const decidedLabel = approved ? "Approved" : "Declined";
|
||||
const decidedLabel = approved ? t("Approved") : t("Declined");
|
||||
return (
|
||||
<div
|
||||
className="row"
|
||||
@@ -298,17 +299,17 @@ function DecidedRow({
|
||||
|
||||
function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
|
||||
if (approval.requestedByType === "employee" && approval.requestedById) {
|
||||
return employees.find((item) => item.id === approval.requestedById)?.displayName ?? "Unknown";
|
||||
return employees.find((item) => item.id === approval.requestedById)?.displayName ?? t("Unknown");
|
||||
}
|
||||
if (approval.requestedByType === "agent" && approval.requestedById) {
|
||||
return agents.find((item) => item.id === approval.requestedById)?.name ?? "Agent";
|
||||
return agents.find((item) => item.id === approval.requestedById)?.name ?? t("Agent");
|
||||
}
|
||||
return "System";
|
||||
return t("System");
|
||||
}
|
||||
|
||||
function resolveDecider(approval: ApprovalRecord, employees: EmployeeRecord[]): string {
|
||||
if (!approval.decidedByEmployeeId) return "—";
|
||||
return employees.find((item) => item.id === approval.decidedByEmployeeId)?.displayName ?? "Unknown";
|
||||
return employees.find((item) => item.id === approval.decidedByEmployeeId)?.displayName ?? t("Unknown");
|
||||
}
|
||||
|
||||
function withinDays(iso: string | null, days: number): boolean {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Field } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { t } from "../i18n/i18n";
|
||||
import { ChatSidebar } from "./chat/ChatSidebar";
|
||||
import { ChatConversation } from "./chat/ChatConversation";
|
||||
import {
|
||||
@@ -203,39 +204,39 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
title="New space"
|
||||
title={t("New space")}
|
||||
open={createOpen}
|
||||
submitLabel="Create"
|
||||
submitLabel={t("Create")}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={createSpace}
|
||||
>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Topic"><input className="input" name="topic" required /></Field>
|
||||
<Field label="Type">
|
||||
<Field label={t("Name")}><input className="input" name="name" required /></Field>
|
||||
<Field label={t("Topic")}><input className="input" name="topic" required /></Field>
|
||||
<Field label={t("Type")}>
|
||||
<select className="input" name="spaceType" defaultValue="project">
|
||||
<option value="company">Company</option>
|
||||
<option value="division">Division</option>
|
||||
<option value="department">Department</option>
|
||||
<option value="project">Project</option>
|
||||
<option value="pod">Pod</option>
|
||||
<option value="ad_hoc">Ad-hoc</option>
|
||||
<option value="direct">Direct</option>
|
||||
<option value="company">{t("Company")}</option>
|
||||
<option value="division">{t("Division")}</option>
|
||||
<option value="department">{t("Department")}</option>
|
||||
<option value="project">{t("Project")}</option>
|
||||
<option value="pod">{t("Pod")}</option>
|
||||
<option value="ad_hoc">{t("Ad-hoc")}</option>
|
||||
<option value="direct">{t("Direct")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Visibility">
|
||||
<Field label={t("Visibility")}>
|
||||
<select className="input" name="visibility" defaultValue="workspace">
|
||||
<option value="workspace">Workspace</option>
|
||||
<option value="restricted">Restricted</option>
|
||||
<option value="workspace">{t("Workspace")}</option>
|
||||
<option value="restricted">{t("Restricted")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Employees">
|
||||
<Field label={t("Employees")}>
|
||||
<select className="input" name="memberEmployeeIds" multiple size={Math.max(1, Math.min(4, employees.length))}>
|
||||
{employees.filter((item) => item.id !== employee.id).map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Agents">
|
||||
<Field label={t("Agents")}>
|
||||
<select className="input" name="memberAgentIds" multiple size={Math.max(1, Math.min(4, agents.length))}>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "../shared/ui";
|
||||
import { t } from "../i18n/i18n";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
import { ConnectedList } from "./connectors/ConnectedList";
|
||||
@@ -41,7 +42,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1060 }}>
|
||||
<h2 style={{ margin: "0 0 18px", fontSize: 16, fontWeight: 600 }}>Connectors</h2>
|
||||
<h2 style={{ margin: "0 0 18px", fontSize: 16, fontWeight: 600 }}>{t("Connectors")}</h2>
|
||||
|
||||
{connectError && (
|
||||
<div
|
||||
@@ -84,7 +85,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
>
|
||||
{catalog.message}
|
||||
</span>
|
||||
<Button size="small" onClick={retry}>Retry</Button>
|
||||
<Button size="small" onClick={retry}>{t("Retry")}</Button>
|
||||
</div>
|
||||
)}
|
||||
{catalog.phase === "ready" && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FILE_SCAN_STATE, type FileObjectRecord, type FileScanState } from "../.
|
||||
import { MenuLabel, MenuRow, Popover } from "../shared/Popover";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatBytes } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
import { FilesTree } from "./files/FilesTree";
|
||||
import { FilesTable } from "./files/FilesTable";
|
||||
import { FilesGrid, PinnedCard } from "./files/FilesGrid";
|
||||
@@ -70,23 +71,23 @@ export function FilesPage({ snapshot, actions }: PageProps) {
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ id: "grid", label: "Grid" },
|
||||
{ id: "list", label: "List" },
|
||||
{ id: "grid", label: t("Grid") },
|
||||
{ id: "list", label: t("List") },
|
||||
]}
|
||||
/>
|
||||
<Button tone="primary" onClick={upload}>
|
||||
<Icon name="upload" size={16} /> Upload
|
||||
<Icon name="upload" size={16} /> {t("Upload")}
|
||||
</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Files</h1>
|
||||
<h1 className="page-h1">{t("Files")}</h1>
|
||||
<p className="page-sub">
|
||||
{visibleFiles.length} {visibleFiles.length === 1 ? "file" : "files"} · {formatBytes(totalBytes)}
|
||||
{visibleFiles.length === 1 ? t("{n} file", { n: visibleFiles.length }) : t("{n} files", { n: visibleFiles.length })} · {formatBytes(totalBytes)}
|
||||
</p>
|
||||
|
||||
<div className="pane-scroll" style={{ paddingBottom: 40 }}>
|
||||
{pinnedFiles.length > 0 && section.kind === "all" && (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Pinned</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Pinned")}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12, marginBottom: 28 }}>
|
||||
{pinnedFiles.slice(0, 8).map((file) => (
|
||||
<PinnedCard
|
||||
@@ -104,7 +105,7 @@ export function FilesPage({ snapshot, actions }: PageProps) {
|
||||
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<div className="section-label">{labelFor(section)}</div>
|
||||
<div style={{ marginLeft: "auto", fontSize: 12, color: "var(--text-3)" }}>Modified ↓</div>
|
||||
<div style={{ marginLeft: "auto", fontSize: 12, color: "var(--text-3)" }}>{t("Modified")} ↓</div>
|
||||
</div>
|
||||
|
||||
{view === "list" ? (
|
||||
@@ -140,21 +141,21 @@ function FilesFilter({
|
||||
onScan: (next: FileScanState | "all") => void;
|
||||
}) {
|
||||
const options: ReadonlyArray<{ id: FileScanState | "all"; label: string }> = [
|
||||
{ id: "all", label: "All files" },
|
||||
{ id: FILE_SCAN_STATE.APPROVED, label: "Clean" },
|
||||
{ id: FILE_SCAN_STATE.BLOCKED, label: "Blocked" },
|
||||
{ id: "all", label: t("All files") },
|
||||
{ id: FILE_SCAN_STATE.APPROVED, label: t("Clean") },
|
||||
{ id: FILE_SCAN_STATE.BLOCKED, label: t("Blocked") },
|
||||
];
|
||||
return (
|
||||
<Popover
|
||||
trigger={({ toggle }) => (
|
||||
<button type="button" className="button ghost" aria-label="Filter files" onClick={toggle}>
|
||||
<button type="button" className="button ghost" aria-label={t("Filter files")} onClick={toggle}>
|
||||
<Icon name="filter" size={16} color={scan !== "all" ? "var(--accent)" : "currentColor"} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<MenuLabel>Scan status</MenuLabel>
|
||||
<MenuLabel>{t("Scan status")}</MenuLabel>
|
||||
{options.map((option) => (
|
||||
<MenuRow key={option.id} active={scan === option.id} onClick={() => onScan(option.id)}>
|
||||
{option.label}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { NewTaskDialog } from "./NewTaskDialog";
|
||||
import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
const STRIP: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
|
||||
const METRICS: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
|
||||
@@ -49,7 +50,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
return (
|
||||
<div className="page-wide" style={{ maxWidth: 1240 }}>
|
||||
<TopbarActions>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> New task</Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> {t("New task")}</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
||||
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
|
||||
@@ -95,13 +96,13 @@ function GoalCard({
|
||||
const percent = progress?.percent ?? 0;
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-label" style={{ marginBottom: 12 }}>Quarter goal</div>
|
||||
<div className="card-label" style={{ marginBottom: 12 }}>{t("Quarter goal")}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 540, lineHeight: 1.4, letterSpacing: "-0.01em", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: 44 }}>
|
||||
{goal ? goal.title : `Set the first goal for ${organizationName}.`}
|
||||
{goal ? goal.title : t("Set the first goal for {organizationName}.", { organizationName })}
|
||||
</div>
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
|
||||
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{progress ? `${percent}%` : "—"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress ? statusLabel(progress.status) : "no work yet"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress ? statusLabel(progress.status) : t("no work yet")}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
|
||||
<div style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
|
||||
@@ -125,10 +126,10 @@ function SpendCard({
|
||||
const fillPct = capCents > 0 ? Math.min(100, Math.round((totalBudgetCents / capCents) * 100)) : 0;
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-label" style={{ marginBottom: 12 }}>Monthly budget</div>
|
||||
<div className="card-label" style={{ marginBottom: 12 }}>{t("Monthly budget")}</div>
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 6 }}>
|
||||
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{money(totalBudgetCents)}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{capCents > 0 ? `/ ${money(capCents)}` : "allocated"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{capCents > 0 ? `/ ${money(capCents)}` : t("allocated")}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
|
||||
<div style={{ width: `${fillPct}%`, height: "100%", background: "var(--text-1)" }} />
|
||||
@@ -143,7 +144,7 @@ function SpendCard({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{connected.length === 0 && <span style={{ fontSize: 14, color: "var(--text-3)" }}>No providers connected</span>}
|
||||
{connected.length === 0 && <span style={{ fontSize: 14, color: "var(--text-3)" }}>{t("No providers connected")}</span>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -165,12 +166,12 @@ function WaitingCard({
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className="card-label">Waiting on you</span>
|
||||
<span className="card-label">{t("Waiting on you")}</span>
|
||||
{approvals.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{approvals.length}</Pill></span>}
|
||||
</div>
|
||||
{approvals.length === 0 ? (
|
||||
<div className="row" style={{ gap: 8, color: "var(--text-3)", fontSize: 14 }}>
|
||||
<Icon name="check" size={16} /><span>You're all caught up</span>
|
||||
<Icon name="check" size={16} /><span>{t("You're all caught up")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
@@ -178,11 +179,11 @@ function WaitingCard({
|
||||
<div key={approval.id}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", margin: "2px 0 8px" }}>
|
||||
{humanizeLabel(approval.kind)} · {humanizeLabel(approval.risk)} risk · from {resolveRequester(approval, employees, agents)}
|
||||
{t("{kind} · {risk} risk · from {requester}", { kind: humanizeLabel(approval.kind), risk: humanizeLabel(approval.risk), requester: resolveRequester(approval, employees, agents) })}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>Approve</Button>
|
||||
<Button size="small" onClick={onOpen}>Open</Button>
|
||||
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>{t("Approve")}</Button>
|
||||
<Button size="small" onClick={onOpen}>{t("Open")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -207,8 +208,8 @@ function RunActivityCard({
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className="card-label">Agent runs</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>All runs →</button>
|
||||
<span className="card-label">{t("Agent runs")}</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>{t("All runs")} →</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{recent.map((run) => {
|
||||
@@ -219,14 +220,14 @@ function RunActivityCard({
|
||||
{agent && <Avatar name={agent.name} size="sm" bot />}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task?.title ?? run.taskId}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{agent?.name ?? "Agent"} · {run.trigger}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{agent?.name ?? t("Agent")} · {run.trigger}</div>
|
||||
</div>
|
||||
<Pill tone={runStateTone(run.state)}>{runStateLabel(run.state)}</Pill>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(run.createdAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No runs yet</div>}
|
||||
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>{t("No runs yet")}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -249,8 +250,8 @@ function RecentMessagesCard({
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className="card-label">Recent messages</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>Open chat →</button>
|
||||
<span className="card-label">{t("Recent messages")}</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>{t("Open chat")} →</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{recent.map((msg) => {
|
||||
@@ -273,7 +274,7 @@ function RecentMessagesCard({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No messages yet</div>}
|
||||
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>{t("No messages yet")}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -291,20 +292,24 @@ function computeGoalProgress(goal: GoalRecord | null, tasks: TaskRecord[]): { pe
|
||||
}
|
||||
|
||||
function statusLabel(status: "ahead" | "on_track" | "behind"): string {
|
||||
if (status === "ahead") return "ahead";
|
||||
if (status === "behind") return "behind";
|
||||
return "on track";
|
||||
if (status === "ahead") return t("ahead");
|
||||
if (status === "behind") return t("behind");
|
||||
return t("on track");
|
||||
}
|
||||
|
||||
function greetingFor(hour: number): string {
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
if (hour < 12) return t("Good morning");
|
||||
if (hour < 17) return t("Good afternoon");
|
||||
return t("Good evening");
|
||||
}
|
||||
|
||||
function renderSummary(pending: number, agents: number): string {
|
||||
const things = `${pending} ${pending === 1 ? "thing" : "things"} waiting on you`;
|
||||
const onDeck = `${agents} ${agents === 1 ? "agent" : "agents"} on deck`;
|
||||
const things = pending === 1
|
||||
? t("{n} thing waiting on you", { n: pending })
|
||||
: t("{n} things waiting on you", { n: pending });
|
||||
const onDeck = agents === 1
|
||||
? t("{n} agent on deck", { n: agents })
|
||||
: t("{n} agents on deck", { n: agents });
|
||||
return `${things} · ${onDeck}`;
|
||||
}
|
||||
|
||||
@@ -323,7 +328,7 @@ function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[],
|
||||
const agent = agents.find((item) => item.id === approval.requestedById);
|
||||
if (agent) return agent.name;
|
||||
}
|
||||
return "System";
|
||||
return t("System");
|
||||
}
|
||||
|
||||
function runStateTone(state: string): "red" | "amber" | "violet" | "green" | undefined {
|
||||
@@ -335,9 +340,9 @@ function runStateTone(state: string): "red" | "amber" | "violet" | "green" | und
|
||||
}
|
||||
|
||||
function runStateLabel(state: string): string {
|
||||
if (state === "queued") return "Queued";
|
||||
if (state === "running") return "Running";
|
||||
if (state === "completed") return "Done";
|
||||
if (state === "failed") return "Failed";
|
||||
if (state === "queued") return t("Queued");
|
||||
if (state === "running") return t("Running");
|
||||
if (state === "completed") return t("Done");
|
||||
if (state === "failed") return t("Failed");
|
||||
return state;
|
||||
}
|
||||
@@ -4,17 +4,18 @@ import { Field } from "../shared/ui";
|
||||
import type { AppSnapshot, RiskLevel, TaskPriority } from "../../../shared/domain";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../../shared/domain";
|
||||
import type { SnapshotActions } from "../app/useSnapshot";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
const RISK_OPTIONS: ReadonlyArray<{ value: RiskLevel; label: string }> = [
|
||||
{ value: RISK_LEVEL.LOW, label: "Low" },
|
||||
{ value: RISK_LEVEL.MEDIUM, label: "Medium" },
|
||||
{ value: RISK_LEVEL.HIGH, label: "High" },
|
||||
{ value: RISK_LEVEL.LOW, label: t("Low") },
|
||||
{ value: RISK_LEVEL.MEDIUM, label: t("Medium") },
|
||||
{ value: RISK_LEVEL.HIGH, label: t("High") },
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS: ReadonlyArray<{ value: TaskPriority; label: string }> = [
|
||||
{ value: TASK_PRIORITY.LOW, label: "Low" },
|
||||
{ value: TASK_PRIORITY.NORMAL, label: "Normal" },
|
||||
{ value: TASK_PRIORITY.HIGH, label: "High" },
|
||||
{ value: TASK_PRIORITY.LOW, label: t("Low") },
|
||||
{ value: TASK_PRIORITY.NORMAL, label: t("Normal") },
|
||||
{ value: TASK_PRIORITY.HIGH, label: t("High") },
|
||||
];
|
||||
|
||||
function value(form: FormData, key: string): string {
|
||||
@@ -64,45 +65,45 @@ export function NewTaskDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="New task" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
|
||||
<Field label="Title"><input className="input" name="title" required autoFocus /></Field>
|
||||
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||
<Field label="Assignee">
|
||||
<Dialog title={t("New task")} open={open} submitLabel={t("Create")} onClose={onClose} onSubmit={submit}>
|
||||
<Field label={t("Title")}><input className="input" name="title" required autoFocus /></Field>
|
||||
<Field label={t("Description")}><textarea className="input" name="description" required /></Field>
|
||||
<Field label={t("Assignee")}>
|
||||
<select className="input" name="assigneeAgentId">
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Project">
|
||||
<Field label={t("Project")}>
|
||||
<select className="input" name="projectId">
|
||||
<option value="">None</option>
|
||||
<option value="">{t("None")}</option>
|
||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Goal">
|
||||
<Field label={t("Goal")}>
|
||||
<select className="input" name="goalId">
|
||||
<option value="">None</option>
|
||||
<option value="">{t("None")}</option>
|
||||
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Workspace">
|
||||
<Field label={t("Workspace")}>
|
||||
<select className="input" name="workspaceId">
|
||||
<option value="">None</option>
|
||||
<option value="">{t("None")}</option>
|
||||
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Risk">
|
||||
<Field label={t("Risk")}>
|
||||
<select className="input" name="risk" defaultValue={RISK_LEVEL.LOW}>
|
||||
{RISK_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Priority">
|
||||
<Field label={t("Priority")}>
|
||||
<select className="input" name="priority" defaultValue={TASK_PRIORITY.NORMAL}>
|
||||
{PRIORITY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="requiresFilesystem" /> Requires workspace access
|
||||
<input type="checkbox" name="requiresFilesystem" /> {t("Requires workspace access")}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,15 @@ import { Avatar, Pill, Segmented } from "../shared/ui";
|
||||
import type { AgentRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatRelative } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
type StatusFilter = "all" | "queued" | "running" | "completed" | "failed";
|
||||
const FILTERS: Array<{ id: StatusFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "queued", label: "Queued" },
|
||||
{ id: "running", label: "Running" },
|
||||
{ id: "completed", label: "Done" },
|
||||
{ id: "failed", label: "Failed" },
|
||||
{ id: "all", label: t("All") },
|
||||
{ id: "queued", label: t("Queued") },
|
||||
{ id: "running", label: t("Running") },
|
||||
{ id: "completed", label: t("Done") },
|
||||
{ id: "failed", label: t("Failed") },
|
||||
];
|
||||
|
||||
export function RunsPage({ snapshot }: PageProps) {
|
||||
@@ -46,13 +47,13 @@ export function RunsPage({ snapshot }: PageProps) {
|
||||
<div className="split-main">
|
||||
<aside className="secondary-pane" style={{ width: 340 }}>
|
||||
<div style={{ padding: "12px 12px 8px" }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>Runs</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>{t("Runs")}</div>
|
||||
<div className="row" style={{ gap: 8, marginBottom: 8, fontSize: 12, color: "var(--text-3)" }}>
|
||||
<span>{queued} queued</span><span>·</span><span>{running} running</span><span>·</span><span>{failed} failed</span>
|
||||
<span>{t("{n} queued", { n: queued })}</span><span>·</span><span>{t("{n} running", { n: running })}</span><span>·</span><span>{t("{n} failed", { n: failed })}</span>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
aria-label="Search runs"
|
||||
aria-label={t("Search runs")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ marginBottom: 8 }}
|
||||
@@ -76,14 +77,14 @@ export function RunsPage({ snapshot }: PageProps) {
|
||||
{task?.title ?? run.taskId}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{agent?.name ?? "Agent"} · {run.trigger}
|
||||
{agent?.name ?? t("Agent")} · {run.trigger}
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone={runTone(run.state)}>{runLabel(run.state)}</Pill>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && <div style={{ padding: 16, fontSize: 14, color: "var(--text-3)" }}>No runs</div>}
|
||||
{filtered.length === 0 && <div style={{ padding: 16, fontSize: 14, color: "var(--text-3)" }}>{t("No runs")}</div>}
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: "auto", padding: 24 }}>
|
||||
@@ -91,7 +92,7 @@ export function RunsPage({ snapshot }: PageProps) {
|
||||
<RunDetail run={selected} tasks={tasks} agents={agents} providers={providers} providerRequests={providerRequests} />
|
||||
) : (
|
||||
<div style={{ display: "grid", placeItems: "center", height: "100%", color: "var(--text-3)", fontSize: 14 }}>
|
||||
Select a run
|
||||
{t("Select a run")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,36 +128,36 @@ function RunDetail({ run, tasks, agents, providers, providerRequests }: {
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: "8px 16px", fontSize: 14, marginBottom: 20 }}>
|
||||
<MetaLabel>Agent</MetaLabel><div>{agent?.name ?? "—"} ({agent?.role ?? "—"})</div>
|
||||
<MetaLabel>Provider</MetaLabel><div>{provider?.label ?? "—"} · {provider?.model ?? "—"}</div>
|
||||
<MetaLabel>Trigger</MetaLabel><div>{run.trigger}</div>
|
||||
<MetaLabel>Created</MetaLabel><div>{formatRelative(run.createdAt)}</div>
|
||||
{run.startedAt && (<><MetaLabel>Started</MetaLabel><div>{formatRelative(run.startedAt)}</div></>)}
|
||||
{run.completedAt && (<><MetaLabel>Completed</MetaLabel><div>{formatRelative(run.completedAt)}</div></>)}
|
||||
{durationMs !== null && (<><MetaLabel>Duration</MetaLabel><div>{formatDuration(durationMs)}</div></>)}
|
||||
<MetaLabel>Context</MetaLabel><div className="mono" style={{ fontSize: 12, overflow: "hidden", textOverflow: "ellipsis" }}>{run.executionContextId}</div>
|
||||
<MetaLabel>{t("Agent")}</MetaLabel><div>{agent?.name ?? "—"} ({agent?.role ?? "—"})</div>
|
||||
<MetaLabel>{t("Provider")}</MetaLabel><div>{provider?.label ?? "—"} · {provider?.model ?? "—"}</div>
|
||||
<MetaLabel>{t("Trigger")}</MetaLabel><div>{run.trigger}</div>
|
||||
<MetaLabel>{t("Created")}</MetaLabel><div>{formatRelative(run.createdAt)}</div>
|
||||
{run.startedAt && (<><MetaLabel>{t("Started")}</MetaLabel><div>{formatRelative(run.startedAt)}</div></>)}
|
||||
{run.completedAt && (<><MetaLabel>{t("Completed")}</MetaLabel><div>{formatRelative(run.completedAt)}</div></>)}
|
||||
{durationMs !== null && (<><MetaLabel>{t("Duration")}</MetaLabel><div>{formatDuration(durationMs)}</div></>)}
|
||||
<MetaLabel>{t("Context")}</MetaLabel><div className="mono" style={{ fontSize: 12, overflow: "hidden", textOverflow: "ellipsis" }}>{run.executionContextId}</div>
|
||||
</div>
|
||||
|
||||
{run.state === "failed" && run.errorMessage && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, borderColor: "var(--red)" }}>
|
||||
<div className="section-label" style={{ color: "var(--red)", marginBottom: 8 }}>Error</div>
|
||||
<div className="section-label" style={{ color: "var(--red)", marginBottom: 8 }}>{t("Error")}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--text-1)", whiteSpace: "pre-wrap", lineHeight: 1.5 }}>{run.errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{request && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div className="section-label" style={{ marginBottom: 8 }}>Provider request</div>
|
||||
<div className="section-label" style={{ marginBottom: 8 }}>{t("Provider request")}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: "8px 16px", fontSize: 14 }}>
|
||||
<MetaLabel>Request ID</MetaLabel><div className="mono" style={{ fontSize: 12, overflow: "hidden", textOverflow: "ellipsis" }}>{request.id}</div>
|
||||
<MetaLabel>Model</MetaLabel><div>{request.model}</div>
|
||||
<MetaLabel>State</MetaLabel><div>{request.state}</div>
|
||||
<MetaLabel>Input chars</MetaLabel><div className="num">{request.inputCharCount.toLocaleString()}</div>
|
||||
<MetaLabel>Output chars</MetaLabel><div className="num">{request.outputCharCount.toLocaleString()}</div>
|
||||
<MetaLabel>{t("Request ID")}</MetaLabel><div className="mono" style={{ fontSize: 12, overflow: "hidden", textOverflow: "ellipsis" }}>{request.id}</div>
|
||||
<MetaLabel>{t("Model")}</MetaLabel><div>{request.model}</div>
|
||||
<MetaLabel>{t("State")}</MetaLabel><div>{request.state}</div>
|
||||
<MetaLabel>{t("Input chars")}</MetaLabel><div className="num">{request.inputCharCount.toLocaleString()}</div>
|
||||
<MetaLabel>{t("Output chars")}</MetaLabel><div className="num">{request.outputCharCount.toLocaleString()}</div>
|
||||
</div>
|
||||
{request.responseText && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="section-label" style={{ marginBottom: 6 }}>Response</div>
|
||||
<div className="section-label" style={{ marginBottom: 6 }}>{t("Response")}</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-2)",
|
||||
@@ -192,10 +193,10 @@ function runTone(state: string): "red" | "amber" | "violet" | "green" | undefine
|
||||
}
|
||||
|
||||
function runLabel(state: string): string {
|
||||
if (state === "queued") return "Queued";
|
||||
if (state === "running") return "Running";
|
||||
if (state === "completed") return "Done";
|
||||
if (state === "failed") return "Failed";
|
||||
if (state === "queued") return t("Queued");
|
||||
if (state === "running") return t("Running");
|
||||
if (state === "completed") return t("Done");
|
||||
if (state === "failed") return t("Failed");
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ProvidersPanel } from "./settings/ProvidersPanel";
|
||||
import { SkillsPanel } from "./settings/SkillsPanel";
|
||||
import { WorkspacePanel } from "./settings/WorkspacePanel";
|
||||
import { AdvancedPanel } from "./settings/AdvancedPanel";
|
||||
import { t } from "../i18n/i18n";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types";
|
||||
|
||||
@@ -14,10 +15,10 @@ interface NavSpec {
|
||||
}
|
||||
|
||||
const TABS: NavSpec[] = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "providers", label: "Providers" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "advanced", label: "Advanced" },
|
||||
{ id: "general", label: t("General") },
|
||||
{ id: "providers", label: t("Providers") },
|
||||
{ id: "skills", label: t("Skills") },
|
||||
{ id: "advanced", label: t("Advanced") },
|
||||
];
|
||||
|
||||
export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
@@ -47,7 +48,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
return (
|
||||
<div className="split-main">
|
||||
<aside className="secondary-pane">
|
||||
<div style={{ fontSize: 14, fontWeight: 600, padding: "4px 8px 14px" }}>Settings</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, padding: "4px 8px 14px" }}>{t("Settings")}</div>
|
||||
<div className="pane-scroll">
|
||||
<NavGroup items={TABS} active={tab} onChange={setTab} />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HiringPipeline } from "./team/HiringPipeline";
|
||||
import type { AgentRecord, EmployeeRecord, ProviderRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, money } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
@@ -96,32 +97,32 @@ export function TeamPage({ snapshot, actions }: PageProps) {
|
||||
return (
|
||||
<div className="page">
|
||||
<TopbarActions>
|
||||
<Button tone="ghost" onClick={() => cycleFilter(filter, setFilter)} title="Filter">
|
||||
<Button tone="ghost" onClick={() => cycleFilter(filter, setFilter)} title={t("Filter")}>
|
||||
<Icon name="filter" size={16} /> {filterLabel(filter)}
|
||||
</Button>
|
||||
<Button tone="primary" onClick={openHireDialog}><Icon name="plus" size={16} /> Hire</Button>
|
||||
<Button tone="primary" onClick={openHireDialog}><Icon name="plus" size={16} /> {t("Hire")}</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Team</h1>
|
||||
<h1 className="page-h1">{t("Team")}</h1>
|
||||
<p className="page-sub">
|
||||
{employees.length + agents.length} {employees.length + agents.length === 1 ? "member" : "members"} ·
|
||||
{" "}{employees.length} {employees.length === 1 ? "human" : "humans"} ·
|
||||
{" "}{agents.length} {agents.length === 1 ? "agent" : "agents"}
|
||||
{employees.length + agents.length === 1 ? t("{n} member", { n: employees.length + agents.length }) : t("{n} members", { n: employees.length + agents.length })} ·{" "}
|
||||
{employees.length === 1 ? t("{n} human", { n: employees.length }) : t("{n} humans", { n: employees.length })} ·{" "}
|
||||
{agents.length === 1 ? t("{n} agent", { n: agents.length }) : t("{n} agents", { n: agents.length })}
|
||||
</p>
|
||||
|
||||
<section className="card" style={{ padding: 32, marginBottom: 28 }}>
|
||||
<div className="section-label">Org</div>
|
||||
<div className="section-label">{t("Org")}</div>
|
||||
<OrgChartTree boardChair={employees[0] ?? null} agents={agents} onSelectAgent={setEditAgentId} />
|
||||
</section>
|
||||
|
||||
<HiringPipeline agents={agents} approvals={snapshot.approvals.filter((a) => a.orgId === orgId)} />
|
||||
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Roster</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Roster")}</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.4fr) minmax(0, 1.4fr) 60px" }}
|
||||
>
|
||||
<div>Member</div><div>Department</div><div>Model</div><div>Budget</div><div />
|
||||
<div>{t("Member")}</div><div>{t("Department")}</div><div>{t("Model")}</div><div>{t("Budget")}</div><div />
|
||||
</div>
|
||||
{rosterRows.map((row, index) => (
|
||||
<RosterRow
|
||||
@@ -133,58 +134,58 @@ export function TeamPage({ snapshot, actions }: PageProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog title="Hire agent" open={hireOpen} submitLabel="Hire" onClose={() => setHireOpen(false)} onSubmit={submit}>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Role"><input className="input" name="role" required /></Field>
|
||||
<Field label="Title"><input className="input" name="title" /></Field>
|
||||
<Field label="Department"><input className="input" name="department" required /></Field>
|
||||
<Field label="Provider">
|
||||
<Dialog title={t("Hire agent")} open={hireOpen} submitLabel={t("Hire")} onClose={() => setHireOpen(false)} onSubmit={submit}>
|
||||
<Field label={t("Name")}><input className="input" name="name" required /></Field>
|
||||
<Field label={t("Role")}><input className="input" name="role" required /></Field>
|
||||
<Field label={t("Title")}><input className="input" name="title" /></Field>
|
||||
<Field label={t("Department")}><input className="input" name="department" required /></Field>
|
||||
<Field label={t("Provider")}>
|
||||
<select className="input" name="modelProviderId" value={hireProviderId} onChange={(event) => updateHireProvider(event.currentTarget.value)}>
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<Field label={t("Model")}>
|
||||
<input className="input" name="modelId" list="agent-model-suggestions" value={hireModelId} onChange={(event) => setHireModelId(event.currentTarget.value)} />
|
||||
</Field>
|
||||
<Field label="Manager">
|
||||
<Field label={t("Manager")}>
|
||||
<select className="input" name="managerAgentId" defaultValue="">
|
||||
<option value="">None</option>
|
||||
<option value="">{t("None")}</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Monthly budget"><input className="input" name="budget" type="number" min="0" step="1" defaultValue="250" /></Field>
|
||||
<Field label="Seniority">
|
||||
<Field label={t("Monthly budget")}><input className="input" name="budget" type="number" min="0" step="1" defaultValue="250" /></Field>
|
||||
<Field label={t("Seniority")}>
|
||||
<select className="input" name="roleLevel" defaultValue="staff">
|
||||
<option value="staff">Staff</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="director">Director</option>
|
||||
<option value="c_suite">Executive</option>
|
||||
<option value="staff">{t("Staff")}</option>
|
||||
<option value="lead">{t("Lead")}</option>
|
||||
<option value="director">{t("Director")}</option>
|
||||
<option value="c_suite">{t("Executive")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
</Dialog>
|
||||
|
||||
{editingAgent && (
|
||||
<Dialog key={editingAgent.id} title={`Edit ${editingAgent.name}`} open submitLabel="Save" onClose={() => setEditAgentId(null)} onSubmit={submitEdit}>
|
||||
<Field label="Role"><input className="input" name="role" defaultValue={editingAgent.role} required /></Field>
|
||||
<Field label="Title"><input className="input" name="title" defaultValue={editingAgent.title} /></Field>
|
||||
<Field label="Department"><input className="input" name="department" defaultValue={editingAgent.department} required /></Field>
|
||||
<Field label="Monthly budget"><input className="input" name="budget" type="number" min="0" step="1" defaultValue={editingAgent.budgetCents / 100} /></Field>
|
||||
<Field label="Provider">
|
||||
<Dialog key={editingAgent.id} title={t("Edit {name}", { name: editingAgent.name })} open submitLabel={t("Save")} onClose={() => setEditAgentId(null)} onSubmit={submitEdit}>
|
||||
<Field label={t("Role")}><input className="input" name="role" defaultValue={editingAgent.role} required /></Field>
|
||||
<Field label={t("Title")}><input className="input" name="title" defaultValue={editingAgent.title} /></Field>
|
||||
<Field label={t("Department")}><input className="input" name="department" defaultValue={editingAgent.department} required /></Field>
|
||||
<Field label={t("Monthly budget")}><input className="input" name="budget" type="number" min="0" step="1" defaultValue={editingAgent.budgetCents / 100} /></Field>
|
||||
<Field label={t("Provider")}>
|
||||
<select className="input" name="modelProviderId" defaultValue={editingAgent.modelProviderId ?? ""}>
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<Field label={t("Model")}>
|
||||
<input className="input" name="modelId" defaultValue={editingAgent.modelId ?? ""} list="agent-model-suggestions" />
|
||||
</Field>
|
||||
<Field label="Seniority">
|
||||
<Field label={t("Seniority")}>
|
||||
<select className="input" name="roleLevel" defaultValue={editingAgent.roleLevel}>
|
||||
<option value="staff">Staff</option>
|
||||
<option value="lead">Lead</option>
|
||||
<option value="director">Director</option>
|
||||
<option value="c_suite">Executive</option>
|
||||
<option value="staff">{t("Staff")}</option>
|
||||
<option value="lead">{t("Lead")}</option>
|
||||
<option value="director">{t("Director")}</option>
|
||||
<option value="c_suite">{t("Executive")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
</Dialog>
|
||||
@@ -227,7 +228,7 @@ function buildRoster(
|
||||
id: employee.id,
|
||||
name: employee.displayName,
|
||||
role: employee.role,
|
||||
dept: "Executive",
|
||||
dept: t("Executive"),
|
||||
model: "—",
|
||||
providerLabel: "—",
|
||||
budgetCents: null,
|
||||
@@ -301,7 +302,7 @@ function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onE
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
{onEdit && <IconButton icon="edit" label={`Edit ${row.name}`} onClick={onEdit} />}
|
||||
{onEdit && <IconButton icon="edit" label={t("Edit {name}", { name: row.name })} onClick={onEdit} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -314,7 +315,7 @@ function cycleFilter(current: "all" | "human" | "agent", set: (value: "all" | "h
|
||||
}
|
||||
|
||||
function filterLabel(filter: "all" | "human" | "agent"): string {
|
||||
if (filter === "human") return "Humans only";
|
||||
if (filter === "agent") return "Agents only";
|
||||
return "All";
|
||||
if (filter === "human") return t("Humans only");
|
||||
if (filter === "agent") return t("Agents only");
|
||||
return t("All");
|
||||
}
|
||||
@@ -25,20 +25,21 @@ import { TimelineView } from "./work/TimelineView";
|
||||
import { canRun, latestTaskRun, nextStatus, prettyRunState, providerFor, runTone } from "./work/task-helpers";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
type View = "board" | "list" | "timeline";
|
||||
|
||||
const COLUMNS: ReadonlyArray<{ id: TaskStatus; label: string; tone: string }> = [
|
||||
{ id: TASK_STATUS.BACKLOG, label: "Backlog", tone: "var(--text-4)" },
|
||||
{ id: TASK_STATUS.IN_PROGRESS, label: "In progress", tone: "var(--accent)" },
|
||||
{ id: TASK_STATUS.REVIEW, label: "In review", tone: "var(--amber)" },
|
||||
{ id: TASK_STATUS.DONE, label: "Done", tone: "var(--green)" },
|
||||
{ id: TASK_STATUS.BACKLOG, label: t("Backlog"), tone: "var(--text-4)" },
|
||||
{ id: TASK_STATUS.IN_PROGRESS, label: t("In progress"), tone: "var(--accent)" },
|
||||
{ id: TASK_STATUS.REVIEW, label: t("In review"), tone: "var(--amber)" },
|
||||
{ id: TASK_STATUS.DONE, label: t("Done"), tone: "var(--green)" },
|
||||
];
|
||||
|
||||
const VIEW_OPTIONS: ReadonlyArray<{ id: View; label: string }> = [
|
||||
{ id: "board", label: "Board" },
|
||||
{ id: "list", label: "List" },
|
||||
{ id: "timeline", label: "Timeline" },
|
||||
{ id: "board", label: t("Board") },
|
||||
{ id: "list", label: t("List") },
|
||||
{ id: "timeline", label: t("Timeline") },
|
||||
];
|
||||
|
||||
export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
@@ -94,13 +95,13 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
onPriority={setPriorityFilter}
|
||||
onAssignee={setAssigneeFilter}
|
||||
/>
|
||||
<Button tone="ghost" onClick={() => setNewGoalOpen(true)}><Icon name="plus" size={16} /> Goal</Button>
|
||||
<Button tone="ghost" onClick={() => setNewProjectOpen(true)}><Icon name="plus" size={16} /> Project</Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> New task</Button>
|
||||
<Button tone="ghost" onClick={() => setNewGoalOpen(true)}><Icon name="plus" size={16} /> {t("Goal")}</Button>
|
||||
<Button tone="ghost" onClick={() => setNewProjectOpen(true)}><Icon name="plus" size={16} /> {t("Project")}</Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> {t("New task")}</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Work</h1>
|
||||
<h1 className="page-h1">{t("Work")}</h1>
|
||||
<p className="page-sub">
|
||||
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {assigneeCount} {assigneeCount === 1 ? "assignee" : "assignees"}
|
||||
{tasks.length === 1 ? t("{n} task", { n: tasks.length }) : t("{n} tasks", { n: tasks.length })} · {assigneeCount === 1 ? t("{n} assignee", { n: assigneeCount }) : t("{n} assignees", { n: assigneeCount })}
|
||||
</p>
|
||||
|
||||
<ProjectOverview projects={projects} tasks={allTasks} agents={agents} workspaces={workspaces} chatSpaces={chatSpaces} />
|
||||
@@ -171,22 +172,22 @@ function WorkFilter({
|
||||
}) {
|
||||
const active = priority !== "all" || assignee !== "all";
|
||||
const priorities: ReadonlyArray<{ id: TaskPriority | "all"; label: string }> = [
|
||||
{ id: "all", label: "All priorities" },
|
||||
{ id: TASK_PRIORITY.HIGH, label: "High" },
|
||||
{ id: TASK_PRIORITY.NORMAL, label: "Normal" },
|
||||
{ id: TASK_PRIORITY.LOW, label: "Low" },
|
||||
{ id: "all", label: t("All priorities") },
|
||||
{ id: TASK_PRIORITY.HIGH, label: t("High") },
|
||||
{ id: TASK_PRIORITY.NORMAL, label: t("Normal") },
|
||||
{ id: TASK_PRIORITY.LOW, label: t("Low") },
|
||||
];
|
||||
return (
|
||||
<Popover
|
||||
trigger={({ toggle }) => (
|
||||
<button type="button" className="button ghost" aria-label="Filter tasks" onClick={toggle}>
|
||||
<button type="button" className="button ghost" aria-label={t("Filter tasks")} onClick={toggle}>
|
||||
<Icon name="filter" size={16} color={active ? "var(--accent)" : "currentColor"} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<MenuLabel>Priority</MenuLabel>
|
||||
<MenuLabel>{t("Priority")}</MenuLabel>
|
||||
{priorities.map((option) => (
|
||||
<MenuRow key={option.id} active={priority === option.id} onClick={() => onPriority(option.id)}>
|
||||
{option.label}
|
||||
@@ -194,9 +195,9 @@ function WorkFilter({
|
||||
</MenuRow>
|
||||
))}
|
||||
<MenuDivider />
|
||||
<MenuLabel>Assignee</MenuLabel>
|
||||
<MenuLabel>{t("Assignee")}</MenuLabel>
|
||||
<MenuRow active={assignee === "all"} onClick={() => onAssignee("all")}>
|
||||
All assignees
|
||||
{t("All assignees")}
|
||||
{assignee === "all" && <span className="menu-check"><Icon name="check" size={16} /></span>}
|
||||
</MenuRow>
|
||||
{agents.map((agent) => (
|
||||
@@ -246,7 +247,7 @@ function BoardView({
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: column.tone, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 540 }}>{column.label}</span>
|
||||
<span className="num" style={{ fontSize: 12, color: "var(--text-3)" }}>{columnTasks.length}</span>
|
||||
<IconButton icon="plus" label="Add task" onClick={onCreate} />
|
||||
<IconButton icon="plus" label={t("Add task")} onClick={onCreate} />
|
||||
</div>
|
||||
{columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -334,8 +335,8 @@ function TaskCard({
|
||||
{latestRun && <Pill tone={runTone(latestRun.state)}>{prettyRunState(latestRun.state)}</Pill>}
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }} onClick={(event) => event.stopPropagation()}>
|
||||
{assignee && <Avatar name={assignee.name} size="sm" bot />}
|
||||
{assignee && <IconButton icon="send" label={canRun(provider) ? `Run ${task.title}` : "Connect a provider to run"} onClick={onRun} disabled={!canRun(provider)} />}
|
||||
{nextStatus(task.status) && <IconButton icon="check" label="Advance" onClick={onAdvance} />}
|
||||
{assignee && <IconButton icon="send" label={canRun(provider) ? t("Run {title}", { title: task.title }) : t("Connect a provider to run")} onClick={onRun} disabled={!canRun(provider)} />}
|
||||
{nextStatus(task.status) && <IconButton icon="check" label={t("Advance")} onClick={onAdvance} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Icon, IconButton } from "../../shared/ui";
|
||||
import type { AgentRecord, EmployeeRecord, FileObjectRecord } from "../../../../shared/domain";
|
||||
import { SLASH_COMMANDS } from "./chat-helpers";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function ChatComposer({
|
||||
disabled,
|
||||
@@ -50,22 +51,22 @@ export function ChatComposer({
|
||||
name="message"
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
ariaLabel="Message"
|
||||
ariaLabel={t("Message")}
|
||||
agents={agents}
|
||||
employees={employees}
|
||||
textareaRef={ref}
|
||||
style={{ minHeight: 38 }}
|
||||
/>
|
||||
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
||||
<IconButton icon="attach" label="Attach file" onClick={onAttach} disabled={disabled} />
|
||||
<IconButton icon="sparkle" label="Commands" onClick={() => setSlashOpen((open) => !open)} />
|
||||
<IconButton icon="attach" label={t("Attach file")} onClick={onAttach} disabled={disabled} />
|
||||
<IconButton icon="sparkle" label={t("Commands")} onClick={() => setSlashOpen((open) => !open)} />
|
||||
{attachment && (
|
||||
<span className="pill" style={{ maxWidth: 220 }}>
|
||||
<Icon name="file" size={14} />
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{attachment.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove attachment"
|
||||
aria-label={t("Remove attachment")}
|
||||
onClick={() => onAttachmentChange("")}
|
||||
style={{ display: "inline-flex", border: 0, background: "transparent", padding: 0, color: "var(--text-3)", cursor: "pointer" }}
|
||||
>
|
||||
@@ -75,7 +76,7 @@ export function ChatComposer({
|
||||
)}
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>Ctrl+Enter</span>
|
||||
<Button tone="primary" size="small" type="submit" disabled={disabled}><Icon name="send" size={12} /> Send</Button>
|
||||
<Button tone="primary" size="small" type="submit" disabled={disabled}><Icon name="send" size={12} /> {t("Send")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{slashOpen && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { ChatComposer } from "./ChatComposer";
|
||||
import { ChatTimeline } from "./ChatTimeline";
|
||||
import type { MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function ChatConversation({
|
||||
space,
|
||||
@@ -142,7 +143,7 @@ function ConversationHeader({
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
aria-label="Search messages"
|
||||
aria-label={t("Search messages")}
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") toggleSearch(); }}
|
||||
@@ -150,7 +151,7 @@ function ConversationHeader({
|
||||
/>
|
||||
)}
|
||||
{!searchOpen && participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
|
||||
<IconButton icon="search" label="Search" onClick={toggleSearch} />
|
||||
<IconButton icon="search" label={t("Search")} onClick={toggleSearch} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "../../../../shared/domain";
|
||||
import { formatTime } from "../types";
|
||||
import { messageAttachments, senderName, type MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function ChatMessageRow({
|
||||
message,
|
||||
@@ -54,11 +55,11 @@ export function ChatMessageRow({
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 8, marginBottom: 2 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 540 }}>{name}</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{formatTime(message.createdAt)}</span>
|
||||
{message.editedAt && !deleted && <span style={{ fontSize: 12, color: "var(--text-3)" }}>(edited)</span>}
|
||||
{message.editedAt && !deleted && <span style={{ fontSize: 12, color: "var(--text-3)" }}>{t("(edited)")}</span>}
|
||||
{canModify && !editing && (
|
||||
<span style={{ marginLeft: "auto", display: "flex", gap: 2 }}>
|
||||
<IconButton icon="edit" label="Edit message" size={16} onClick={() => setEditing(true)} />
|
||||
<IconButton icon="x" label="Delete message" size={16} onClick={onDelete} />
|
||||
<IconButton icon="edit" label={t("Edit message")} size={16} onClick={() => setEditing(true)} />
|
||||
<IconButton icon="x" label={t("Delete message")} size={16} onClick={onDelete} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -68,20 +69,20 @@ export function ChatMessageRow({
|
||||
</div>
|
||||
)}
|
||||
{deleted ? (
|
||||
<div style={{ fontSize: 14, color: "var(--text-3)", fontStyle: "italic" }}>Message deleted</div>
|
||||
<div style={{ fontSize: 14, color: "var(--text-3)", fontStyle: "italic" }}>{t("Message deleted")}</div>
|
||||
) : editing ? (
|
||||
<form onSubmit={submitEdit}>
|
||||
<textarea
|
||||
name="body"
|
||||
defaultValue={message.bodyMd}
|
||||
autoFocus
|
||||
aria-label="Edit message"
|
||||
aria-label={t("Edit message")}
|
||||
className="input"
|
||||
style={{ minHeight: 60, marginBottom: 6 }}
|
||||
/>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Button tone="primary" size="small" type="submit">Save</Button>
|
||||
<Button tone="ghost" size="small" onClick={() => setEditing(false)}>Cancel</Button>
|
||||
<Button tone="primary" size="small" type="submit">{t("Save")}</Button>
|
||||
<Button tone="ghost" size="small" onClick={() => setEditing(false)}>{t("Cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
@@ -121,7 +122,7 @@ function renderBody(body: string, mentions: ChatMentionRecord[], context: Messag
|
||||
parts.push(
|
||||
<span
|
||||
key={`m-${mention.messageId}-${mention.offsetStart}`}
|
||||
title={failed ? "Agent could not be reached" : undefined}
|
||||
title={failed ? t("Agent could not be reached") : undefined}
|
||||
style={{
|
||||
background: failed ? "rgba(255,100,80,0.1)" : "var(--accent-soft)",
|
||||
color: failed ? "var(--text-3)" : "var(--accent)",
|
||||
@@ -175,8 +176,8 @@ function mentionLabel(mention: ChatMentionRecord, context: MessageContext): stri
|
||||
if (!agent) throw new Error("Message mention agent target is missing.");
|
||||
return agent.name;
|
||||
}
|
||||
if (mention.targetType === "board") return "Board";
|
||||
if (mention.targetType === "org_group" && mention.targetId.endsWith(":oncall")) return "OnCall";
|
||||
if (mention.targetType === "board") return t("Board");
|
||||
if (mention.targetType === "org_group" && mention.targetId.endsWith(":oncall")) return t("OnCall");
|
||||
throw new Error("Message mention target type is unsupported.");
|
||||
}
|
||||
|
||||
@@ -213,13 +214,13 @@ function AttachmentCard({ file, handoff = false, onOpen }: { file: FileObjectRec
|
||||
<span style={{ fontSize: 12, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.name}</span>
|
||||
{handoff && (
|
||||
<span className="pill violet" style={{ flexShrink: 0 }}>
|
||||
<Icon name="bot" size={10} /> AI → AI
|
||||
<Icon name="bot" size={10} /> {t("AI → AI")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{file.kind.toUpperCase()}</div>
|
||||
</div>
|
||||
{canOpen && <IconButton icon="arrowUpRight" label={`Open ${file.name}`} onClick={onOpen ?? undefined} />}
|
||||
{canOpen && <IconButton icon="arrowUpRight" label={t("Open {name}", { name: file.name })} onClick={onOpen ?? undefined} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Avatar, Icon, IconButton } from "../../shared/ui";
|
||||
import type { ChatPresenceRecord } from "../../../../shared/domain";
|
||||
import { formatRelative } from "../types";
|
||||
import type { ConversationItem } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
function presenceColor(status: string | undefined): string | null {
|
||||
switch (status) {
|
||||
@@ -40,8 +41,8 @@ export function ChatSidebar({
|
||||
return (
|
||||
<aside className="secondary-pane">
|
||||
<div className="row" style={{ padding: "4px 8px 12px" }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>Messages</div>
|
||||
<IconButton icon="plus" label="New space" onClick={onCreateSpace} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{t("Messages")}</div>
|
||||
<IconButton icon="plus" label={t("New space")} onClick={onCreateSpace} />
|
||||
</div>
|
||||
<div className="pane-scroll">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../../../../shared/domain";
|
||||
import { ChatMessageRow } from "./ChatMessageRow";
|
||||
import { formatDay, type MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const WINDOW_SIZE = 150;
|
||||
const EMPTY_ATTACHMENTS: ChatAttachmentRecord[] = [];
|
||||
@@ -70,7 +71,7 @@ export function ChatTimeline({
|
||||
elements.push(
|
||||
<div key="show-earlier" style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}>
|
||||
<Button size="small" onClick={() => setVisibleCount((count) => count + WINDOW_SIZE)}>
|
||||
Show earlier messages
|
||||
{t("Show earlier messages")}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
@@ -121,14 +122,14 @@ function ApprovalInline({ approval, onApprove, onDecline }: { approval: Approval
|
||||
<div style={{ margin: "14px 0 18px 44px", maxWidth: 480, border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: 14 }}>
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
<Icon name="approve" size={14} color="var(--amber)" />
|
||||
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540 }}>Approval needed</span>
|
||||
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540 }}>{t("Approval needed")}</span>
|
||||
<Pill tone={approval.risk === "high" ? "red" : approval.risk === "medium" ? "amber" : undefined}>{approval.risk}</Pill>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 12, lineHeight: 1.5 }}>{approval.details}</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Button tone="primary" size="small" onClick={onApprove}>Approve</Button>
|
||||
<Button size="small" onClick={onDecline}>Decline</Button>
|
||||
<Button tone="primary" size="small" onClick={onApprove}>{t("Approve")}</Button>
|
||||
<Button size="small" onClick={onDecline}>{t("Decline")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
EmployeeRecord,
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export interface MessageContext {
|
||||
employees: EmployeeRecord[];
|
||||
@@ -26,8 +27,8 @@ export function senderName(message: ChatMessageRecord, context: MessageContext):
|
||||
if (!agent) throw new Error("Message agent sender is missing.");
|
||||
return agent.name;
|
||||
}
|
||||
if (message.senderType === "board") return "Board";
|
||||
return "System";
|
||||
if (message.senderType === "board") return t("Board");
|
||||
return t("System");
|
||||
}
|
||||
|
||||
export function messageAttachments(
|
||||
@@ -172,7 +173,7 @@ function resolveCounterpart(
|
||||
}
|
||||
|
||||
function previewText(message: ChatMessageRecord): string {
|
||||
if (message.deletedAt) return "Message deleted";
|
||||
if (message.deletedAt) return t("Message deleted");
|
||||
const firstLine = message.bodyMd.split("\n").find((line) => line.trim()) ?? "";
|
||||
return firstLine.trim();
|
||||
}
|
||||
@@ -181,10 +182,10 @@ export function formatDay(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
const today = new Date();
|
||||
if (sameDay(date, today)) return "Today";
|
||||
if (sameDay(date, today)) return t("Today");
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (sameDay(date, yesterday)) return "Yesterday";
|
||||
if (sameDay(date, yesterday)) return t("Yesterday");
|
||||
return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, IconButton, Pill } from "../../shared/ui";
|
||||
import type { AgentRecord, ConnectorAccountRecord, ConnectorGrantRecord } from "../../../../shared/domain";
|
||||
import { ToolkitTile } from "./ToolkitCatalog";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const STATUS_TONE: Record<string, "green" | "amber" | "red" | undefined> = {
|
||||
ACTIVE: "green",
|
||||
@@ -41,7 +42,7 @@ export function ConnectedList({
|
||||
const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]));
|
||||
return (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<div className="section-label" style={{ marginBottom: 12 }}>Connected</div>
|
||||
<div className="section-label" style={{ marginBottom: 12 }}>{t("Connected")}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{accounts.map((account) => {
|
||||
const holders = grants
|
||||
@@ -62,11 +63,11 @@ export function ConnectedList({
|
||||
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
|
||||
<span className="row" style={{ gap: 6, marginLeft: "auto" }}>
|
||||
<Button size="small" disabled={busy} onClick={() => onReconnect(account.toolkitSlug)}>
|
||||
{busySlug === account.toolkitSlug && busyAction === "connect" ? "Opening…" : "Reconnect"}
|
||||
{busySlug === account.toolkitSlug && busyAction === "connect" ? t("Opening…") : t("Reconnect")}
|
||||
</Button>
|
||||
<IconButton
|
||||
icon="x"
|
||||
label={`Delete ${account.toolkitName} connection`}
|
||||
label={t("Delete {name} connection", { name: account.toolkitName })}
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(account.toolkitSlug)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Icon, Pill } from "../../shared/ui";
|
||||
import type { ConnectorCatalogEntry } from "../../../../shared/commands";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/** Deterministic tile color for a toolkit's letter glyph. */
|
||||
const TILE_COLORS = ["#5e6ad2", "#3b82a0", "#7c5cb0", "#3f8f5f", "#b0684f", "#a05c86", "#6f7d3d", "#4f6fb0"];
|
||||
@@ -65,14 +66,14 @@ export function ToolkitCatalog({
|
||||
return (
|
||||
<section>
|
||||
<div className="row" style={{ marginBottom: 12, gap: 10 }}>
|
||||
<div className="section-label" style={{ flex: 1 }}>Catalog</div>
|
||||
<div className="section-label" style={{ flex: 1 }}>{t("Catalog")}</div>
|
||||
<div className="row" style={{ gap: 6, background: "var(--panel-2)", borderRadius: 8, padding: "5px 10px", width: 240 }}>
|
||||
<Icon name="search" size={13} color="var(--text-3)" />
|
||||
<input
|
||||
aria-label="Search connectors"
|
||||
aria-label={t("Search connectors")}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search connectors"
|
||||
placeholder={t("Search connectors")}
|
||||
style={{ border: 0, outline: 0, background: "transparent", fontSize: 13, flex: 1, minWidth: 0, color: "var(--text-1)" }}
|
||||
/>
|
||||
</div>
|
||||
@@ -98,10 +99,10 @@ export function ToolkitCatalog({
|
||||
{entry.name}
|
||||
</span>
|
||||
{connected ? (
|
||||
<Pill tone="green" dot>Connected</Pill>
|
||||
<Pill tone="green" dot>{t("Connected")}</Pill>
|
||||
) : (
|
||||
<Button size="small" disabled={busySlug !== null} onClick={() => onConnect(entry.slug)}>
|
||||
{busySlug === entry.slug ? "Opening…" : "Connect"}
|
||||
{busySlug === entry.slug ? t("Opening…") : t("Connect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { formatRelative } from "../types";
|
||||
import { FileIcon, ScanLabelStrip } from "./FileVisuals";
|
||||
import { fileGrantCount, isAgentOwner, resolveOwnerName, shortChecksum, shortObjectRef } from "./file-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function FilesGrid({
|
||||
files,
|
||||
@@ -77,7 +78,7 @@ export function PinnedCard({
|
||||
<FileIcon kind={file.kind} big />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={file.pinned ? "Unpin" : "Pin"}
|
||||
aria-label={file.pinned ? t("Unpin") : t("Pin")}
|
||||
onClick={(event) => { event.stopPropagation(); onUnpin(); }}
|
||||
style={{ marginLeft: "auto", padding: 4, border: 0, background: "transparent", color: file.pinned ? "var(--accent)" : "var(--text-4)", cursor: "pointer" }}
|
||||
>
|
||||
@@ -91,7 +92,7 @@ export function PinnedCard({
|
||||
sha:{shortChecksum(file.sha256)} · obj:{shortObjectRef(file.storagePath)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone={file.scanState === "approved" ? "green" : "red"}>{file.scanState === "approved" ? "Clean" : "Blocked"}</Pill>
|
||||
<Pill tone={file.scanState === "approved" ? "green" : "red"}>{file.scanState === "approved" ? t("Clean") : t("Blocked")}</Pill>
|
||||
<span className="mono" style={{ color: "var(--text-3)", fontSize: 12 }}>{grantCount}</span>
|
||||
</div>
|
||||
<ScanLabelStrip file={file} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { formatBytes, formatRelative } from "../types";
|
||||
import { FileIcon } from "./FileVisuals";
|
||||
import { fileGrantCount, isAgentOwner, resolveOwnerName, shortChecksum, shortObjectRef } from "./file-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const ROW_COLUMNS = "minmax(0, 2.7fr) minmax(70px, 0.7fr) minmax(0, 1.2fr) minmax(94px, 0.8fr) minmax(88px, 0.8fr) 56px";
|
||||
|
||||
@@ -30,7 +31,7 @@ export function FilesTable({
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: ROW_COLUMNS }}>
|
||||
<div>Name</div><div>Size</div><div>Owner</div><div>Access</div><div>Modified</div><div />
|
||||
<div>{t("Name")}</div><div>{t("Size")}</div><div>{t("Owner")}</div><div>{t("Access")}</div><div>{t("Modified")}</div><div />
|
||||
</div>
|
||||
{files.map((file, index) => (
|
||||
<FileRow
|
||||
@@ -80,7 +81,7 @@ function FileRow({
|
||||
sha:{shortChecksum(file.sha256)} · obj:{shortObjectRef(file.storagePath)}
|
||||
</span>
|
||||
</div>
|
||||
{file.pinned && <Pill tone="violet" dot>Pinned</Pill>}
|
||||
{file.pinned && <Pill tone="violet" dot>{t("Pinned")}</Pill>}
|
||||
</div>
|
||||
<div className="num mono" style={{ color: "var(--text-3)", fontSize: 12 }}>{formatBytes(file.sizeBytes)}</div>
|
||||
<div className="row" style={{ gap: 8, minWidth: 0 }}>
|
||||
@@ -88,16 +89,16 @@ function FileRow({
|
||||
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{owner}</span>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, minWidth: 0 }}>
|
||||
<Pill tone={canOpen ? "green" : "red"}>{canOpen ? "Clean" : "Blocked"}</Pill>
|
||||
<Pill tone={canOpen ? "green" : "red"}>{canOpen ? t("Clean") : t("Blocked")}</Pill>
|
||||
<span className="mono" style={{ color: "var(--text-3)", fontSize: 12 }}>{grantCount}</span>
|
||||
</div>
|
||||
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(file.updatedAt ?? file.createdAt)}</div>
|
||||
<div style={{ textAlign: "right" }} onClick={(event) => event.stopPropagation()}>
|
||||
<Popover trigger={({ toggle }) => <IconButton icon="more" label={`Actions for ${file.name}`} onClick={toggle} />}>
|
||||
<Popover trigger={({ toggle }) => <IconButton icon="more" label={t("Actions for {name}", { name: file.name })} onClick={toggle} />}>
|
||||
{(close) => (
|
||||
<>
|
||||
{canOpen && <MenuRow onClick={() => { onOpen(); close(); }}>Open</MenuRow>}
|
||||
<MenuRow onClick={() => { onTogglePin(); close(); }}>{file.pinned ? "Unpin" : "Pin"}</MenuRow>
|
||||
{canOpen && <MenuRow onClick={() => { onOpen(); close(); }}>{t("Open")}</MenuRow>}
|
||||
<MenuRow onClick={() => { onTogglePin(); close(); }}>{file.pinned ? t("Unpin") : t("Pin")}</MenuRow>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Icon } from "../../shared/ui";
|
||||
import type { AgentRecord, FileObjectRecord } from "../../../../shared/domain";
|
||||
import { isRecent, type Section } from "./file-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function FilesTree({
|
||||
files,
|
||||
@@ -20,31 +21,31 @@ export function FilesTree({
|
||||
const recent = files.filter((file) => isRecent(file.createdAt));
|
||||
return (
|
||||
<aside style={{ overflowY: "auto", minHeight: 0, paddingBottom: 40 }}>
|
||||
<div className="section-label">Library</div>
|
||||
<div className="section-label">{t("Library")}</div>
|
||||
<TreeItem
|
||||
icon="folder"
|
||||
label="All files"
|
||||
label={t("All files")}
|
||||
count={files.length}
|
||||
active={section.kind === "all"}
|
||||
onClick={() => onChange({ kind: "all" })}
|
||||
/>
|
||||
<TreeItem
|
||||
icon="sparkle"
|
||||
label="Recent"
|
||||
label={t("Recent")}
|
||||
count={recent.length}
|
||||
active={section.kind === "recent"}
|
||||
onClick={() => onChange({ kind: "recent" })}
|
||||
/>
|
||||
<TreeItem
|
||||
icon="pin"
|
||||
label="Pinned"
|
||||
label={t("Pinned")}
|
||||
count={pinnedFiles.length}
|
||||
active={section.kind === "pinned"}
|
||||
onClick={() => onChange({ kind: "pinned" })}
|
||||
/>
|
||||
{departments.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ marginTop: 22 }}>Spaces</div>
|
||||
<div className="section-label" style={{ marginTop: 22 }}>{t("Spaces")}</div>
|
||||
{departments.map((department) => (
|
||||
<TreeItem
|
||||
key={department.name}
|
||||
@@ -59,7 +60,7 @@ export function FilesTree({
|
||||
)}
|
||||
{filesByAgent.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ marginTop: 22 }}>By agent</div>
|
||||
<div className="section-label" style={{ marginTop: 22 }}>{t("By agent")}</div>
|
||||
{filesByAgent.map(({ agent, count }) => (
|
||||
<TreeItem
|
||||
key={agent.id}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
FileAccessGrantRecord,
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
|
||||
@@ -100,13 +101,13 @@ export function resolveOwnerName(file: FileObjectRecord, employees: EmployeeReco
|
||||
const agent = agents.find((item) => item.id === file.ownerId);
|
||||
if (agent) return agent.name;
|
||||
}
|
||||
return "System";
|
||||
return t("System");
|
||||
}
|
||||
|
||||
export function labelFor(section: Section): string {
|
||||
if (section.kind === "all") return "All files";
|
||||
if (section.kind === "recent") return "Recent";
|
||||
if (section.kind === "pinned") return "Pinned";
|
||||
if (section.kind === "agent") return "By agent";
|
||||
if (section.kind === "all") return t("All files");
|
||||
if (section.kind === "recent") return t("Recent");
|
||||
if (section.kind === "pinned") return t("Pinned");
|
||||
if (section.kind === "agent") return t("By agent");
|
||||
return section.value;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { AgentRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
|
||||
import { TASK_STATUS } from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const CHART_H = 116;
|
||||
const CHART_W = 260;
|
||||
@@ -29,10 +30,10 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t
|
||||
const max = Math.max(1, ...data.map((d) => d.count));
|
||||
const barW = data.length > 0 ? (CHART_W - (data.length - 1) * BAR_GAP) / data.length : 0;
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const readout = hover !== null && data[hover] ? `${data[hover]!.name} · ${data[hover]!.count}` : `${total} open`;
|
||||
const readout = hover !== null && data[hover] ? `${data[hover]!.name} · ${data[hover]!.count}` : t("{n} open", { n: total });
|
||||
|
||||
return (
|
||||
<ChartCard title="Agent workload" readout={readout}>
|
||||
<ChartCard title={t("Agent workload")} readout={readout}>
|
||||
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
|
||||
{data.map((d, i) => {
|
||||
const h = (d.count / max) * (CHART_H - 22);
|
||||
@@ -65,10 +66,10 @@ export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
|
||||
const max = Math.max(1, ...buckets.map((b) => b.count));
|
||||
const barW = (CHART_W - 6 * BAR_GAP) / 7;
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : `${total} this week`;
|
||||
const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : t("{n} this week", { n: total });
|
||||
|
||||
return (
|
||||
<ChartCard title="Run activity" readout={readout}>
|
||||
<ChartCard title={t("Run activity")} readout={readout}>
|
||||
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
|
||||
{buckets.map((b, i) => {
|
||||
const h = (b.count / max) * (CHART_H - 24);
|
||||
@@ -104,7 +105,7 @@ export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
|
||||
const offset = circumference - (rate / 100) * circumference;
|
||||
|
||||
return (
|
||||
<ChartCard title="Success rate" readout={`${completed} ok · ${failed} failed`}>
|
||||
<ChartCard title={t("Success rate")} readout={t("{ok} ok · {failed} failed", { ok: completed, failed })}>
|
||||
<svg viewBox="0 0 100 100" style={{ width: 96, height: 96, margin: "4px auto", display: "block" }}>
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
|
||||
<circle
|
||||
@@ -140,10 +141,10 @@ export function CostTrendChart({ providerRequests }: { providerRequests: Array<{
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const readout = hover !== null && charBuckets[hover]
|
||||
? `${charBuckets[hover]!.label} · ${kchars(charBuckets[hover]!.chars)}`
|
||||
: `${kchars(totalChars)} this week`;
|
||||
: t("{n} this week", { n: kchars(totalChars) });
|
||||
|
||||
return (
|
||||
<ChartCard title="Token usage" readout={readout}>
|
||||
<ChartCard title={t("Token usage")} readout={readout}>
|
||||
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
|
||||
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{coords.map((c, i) => (
|
||||
@@ -163,16 +164,17 @@ function kchars(chars: number): string {
|
||||
return String(chars);
|
||||
}
|
||||
|
||||
const weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
|
||||
function last7DayBuckets(dates: string[]): Array<{ label: string; count: number }> {
|
||||
const now = Date.now();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const offset = 6 - i;
|
||||
const start = new Date(now - offset * 86400000);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start.getTime() + 86400000);
|
||||
const count = dates.filter((d) => { const t = new Date(d).getTime(); return t >= start.getTime() && t < end.getTime(); }).length;
|
||||
return { label: dayNames[start.getDay()] ?? "", count };
|
||||
return { label: weekdayFormat.format(start), count };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Avatar, Icon, IconButton, Pill } from "../../shared/ui";
|
||||
import { humanizeToken } from "../types";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type { AgentActivity, OfficeAgentInput } from "./office-types";
|
||||
|
||||
const ACTIVITY_LABEL: Record<AgentActivity, string> = {
|
||||
active: "Active",
|
||||
thinking: "Thinking",
|
||||
busy: "Busy",
|
||||
idle: "Idle",
|
||||
active: t("Active"),
|
||||
thinking: t("Thinking"),
|
||||
busy: t("Busy"),
|
||||
idle: t("Idle"),
|
||||
};
|
||||
|
||||
const ACTIVITY_TONE: Record<AgentActivity, "green" | "violet" | "amber" | undefined> = {
|
||||
@@ -21,7 +22,7 @@ export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; o
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`Agent ${agent.name}`}
|
||||
aria-label={t("Agent {name}", { name: agent.name })}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
@@ -40,7 +41,7 @@ export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; o
|
||||
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3, wordBreak: "break-word" }}>{agent.name}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-2)", marginTop: 1, wordBreak: "break-word" }}>{agent.title}</div>
|
||||
</div>
|
||||
<IconButton icon="x" label="Close agent details" onClick={onClose} />
|
||||
<IconButton icon="x" label={t("Close agent details")} onClick={onClose} />
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
|
||||
<Pill>{agent.department}</Pill>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AmenitySpot, DeskSlot, FloorPlan, LaneGraph, Rect, RoomSpec, Vec } from "./office-types";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/**
|
||||
* Fixed-size world geometry (logical px). The office is a north band of rooms
|
||||
@@ -45,10 +46,10 @@ const CORRIDOR_PLANTS: Rect[] = [
|
||||
];
|
||||
|
||||
const ROOMS: RoomSpec[] = [
|
||||
{ kind: "ceo", label: "CEO OFFICE", bounds: { x: 16, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 160, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "meeting", label: "MEETING ROOM", bounds: { x: 320, y: ROOM_TOP, w: 304, h: 256 }, doorCx: 472, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "snack", label: "SNACK BAR", bounds: { x: 640, y: ROOM_TOP, w: 256, h: 256 }, doorCx: 768, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "restroom", label: "RESTROOM", bounds: { x: 912, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 1056, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "ceo", label: t("CEO OFFICE"), bounds: { x: 16, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 160, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "meeting", label: t("MEETING ROOM"), bounds: { x: 320, y: ROOM_TOP, w: 304, h: 256 }, doorCx: 472, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "snack", label: t("SNACK BAR"), bounds: { x: 640, y: ROOM_TOP, w: 256, h: 256 }, doorCx: 768, interiorLaneY: INTERIOR_LANE_Y },
|
||||
{ kind: "restroom", label: t("RESTROOM"), bounds: { x: 912, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 1056, interiorLaneY: INTERIOR_LANE_Y },
|
||||
];
|
||||
|
||||
const AMENITIES: AmenitySpot[] = [
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { BillingPanel } from "./BillingPanel";
|
||||
import { ProviderRequestPanel } from "./ProviderRequestPanel";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/** The low-frequency operator surfaces, one section each. */
|
||||
export function AdvancedPanel({
|
||||
@@ -33,7 +34,7 @@ export function AdvancedPanel({
|
||||
const connected = providers.filter((provider) => provider.enabled);
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Advanced</h1>
|
||||
<h1 className="page-h1">{t("Advanced")}</h1>
|
||||
|
||||
{connected.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Avatar, Pill } from "../../shared/ui";
|
||||
import type { AgentRecord, HiringPolicyRecord, ProviderRecord, ProviderRequestRecord } from "../../../../shared/domain";
|
||||
import { money } from "../types";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function BillingPanel({
|
||||
agents,
|
||||
@@ -23,39 +24,39 @@ export function BillingPanel({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Billing</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Billing")}</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, marginBottom: 20 }}>
|
||||
<StatTile label="Monthly allocation" value={money(totalBudget)} />
|
||||
<StatTile label="Budget cap" value={totalCap > 0 ? money(totalCap) : "—"} />
|
||||
<StatTile label="Requests (30d)" value={String(monthRequests.length)} />
|
||||
<StatTile label="Failures (30d)" value={String(failedRequests.length)} tone={failedRequests.length > 0 ? "red" : undefined} />
|
||||
<StatTile label={t("Monthly allocation")} value={money(totalBudget)} />
|
||||
<StatTile label={t("Budget cap")} value={totalCap > 0 ? money(totalCap) : "—"} />
|
||||
<StatTile label={t("Requests (30d)")} value={String(monthRequests.length)} />
|
||||
<StatTile label={t("Failures (30d)")} value={String(failedRequests.length)} tone={failedRequests.length > 0 ? "red" : undefined} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 20 }}>
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<div className="card-label" style={{ marginBottom: 8 }}>Token usage (30d)</div>
|
||||
<div className="card-label" style={{ marginBottom: 8 }}>{t("Token usage (30d)")}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "6px 14px", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text-3)" }}>Input chars</span><span className="num">{totalInputChars.toLocaleString()}</span>
|
||||
<span style={{ color: "var(--text-3)" }}>Output chars</span><span className="num">{totalOutputChars.toLocaleString()}</span>
|
||||
<span style={{ color: "var(--text-3)" }}>Total</span><span className="num">{(totalInputChars + totalOutputChars).toLocaleString()}</span>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Input chars")}</span><span className="num">{totalInputChars.toLocaleString()}</span>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Output chars")}</span><span className="num">{totalOutputChars.toLocaleString()}</span>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Total")}</span><span className="num">{(totalInputChars + totalOutputChars).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<div className="card-label" style={{ marginBottom: 8 }}>Budget utilization</div>
|
||||
<div className="card-label" style={{ marginBottom: 8 }}>{t("Budget utilization")}</div>
|
||||
<div style={{ height: 6, background: "var(--panel-3)", borderRadius: 3, overflow: "hidden", marginBottom: 8 }}>
|
||||
<div style={{ width: `${totalCap > 0 ? Math.min(100, Math.round((totalBudget / totalCap) * 100)) : 0}%`, height: "100%", background: "var(--accent)" }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>
|
||||
{totalCap > 0 ? `${Math.round((totalBudget / totalCap) * 100)}% of cap` : "No budget cap set"}
|
||||
{totalCap > 0 ? t("{percent}% of cap", { percent: Math.round((totalBudget / totalCap) * 100) }) : t("No budget cap set")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Cost by agent</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Cost by agent")}</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 20 }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}>
|
||||
<div>Agent</div><div>Budget</div><div>Requests</div><div>Input</div><div>Output</div>
|
||||
<div>{t("Agent")}</div><div>{t("Budget")}</div><div>{t("Requests")}</div><div>{t("Input")}</div><div>{t("Output")}</div>
|
||||
</div>
|
||||
{agentSpend.map((row, i) => (
|
||||
<div key={row.agent.id} className="table-row" style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: i === agentSpend.length - 1 ? "none" : "1px solid var(--border-soft)" }}>
|
||||
@@ -71,10 +72,10 @@ export function BillingPanel({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Provider activity (30d)</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Provider activity (30d)")}</div>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }}>
|
||||
<div>Provider</div><div>Requests</div><div>Errors</div><div>Agents</div>
|
||||
<div>{t("Provider")}</div><div>{t("Requests")}</div><div>{t("Errors")}</div><div>{t("Agents")}</div>
|
||||
</div>
|
||||
{providers.map((provider, index) => {
|
||||
const requests = monthRequests.filter((r) => r.providerId === provider.id);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import type { ProviderCatalogEntry, ProviderRecord } from "../../../../shared/domain";
|
||||
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
|
||||
import { Field } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
interface CloudFields {
|
||||
baseUrl: string;
|
||||
@@ -23,7 +24,7 @@ export function ProviderConnectFields({
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="API key"><input className="input" name="secret" type="password" required autoFocus /></Field>
|
||||
<Field label={t("API key")}><input className="input" name="secret" type="password" required autoFocus /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
@@ -51,7 +52,7 @@ export function ProviderOAuthFields({
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Default model">
|
||||
<Field label={t("Default model")}>
|
||||
{entry.models.length ? (
|
||||
<select className="input" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||
{entry.models.map((option) => (
|
||||
@@ -78,7 +79,7 @@ function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pr
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Base URL">
|
||||
<Field label={t("Base URL")}>
|
||||
<input
|
||||
className="input"
|
||||
name="cloudBaseUrl"
|
||||
@@ -86,7 +87,7 @@ function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pr
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API key"><input className="input" name="secret" type="password" required /></Field>
|
||||
<Field label={t("API key")}><input className="input" name="secret" type="password" required /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
@@ -114,7 +115,7 @@ function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provide
|
||||
};
|
||||
|
||||
return (
|
||||
<Field label="Default model">
|
||||
<Field label={t("Default model")}>
|
||||
{models.length ? (
|
||||
<select className="input" name="model" value={value} onChange={(event) => setValue(event.target.value)}>
|
||||
{models.map((model) => (
|
||||
@@ -126,7 +127,7 @@ function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provide
|
||||
)}
|
||||
{connected && (
|
||||
<button type="button" className="button ghost" style={{ marginTop: 6 }} disabled={loading} onClick={refresh}>
|
||||
{loading ? "Loading models..." : "Refresh models"}
|
||||
{loading ? t("Loading models...") : t("Refresh models")}
|
||||
</button>
|
||||
)}
|
||||
{error && <div style={{ color: "var(--red)", fontSize: 12, marginTop: 4 }}>{error}</div>}
|
||||
@@ -140,22 +141,22 @@ function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pro
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Base URL">
|
||||
<Field label={t("Base URL")}>
|
||||
<input className="input" name="cloudBaseUrl" value={state.baseUrl} onChange={(event) => update({ baseUrl: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Region">
|
||||
<Field label={t("Region")}>
|
||||
<input className="input" name="cloudRegion" value={state.region} onChange={(event) => update({ region: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Project / resource">
|
||||
<Field label={t("Project / resource")}>
|
||||
<input className="input" name="cloudProject" value={state.projectId} onChange={(event) => update({ projectId: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Deployment / model id">
|
||||
<Field label={t("Deployment / model id")}>
|
||||
<input className="input" name="cloudDeployment" value={state.deployment} onChange={(event) => update({ deployment: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Credential">
|
||||
<Field label={t("Credential")}>
|
||||
<input className="input" name="secret" type="password" required autoFocus />
|
||||
</Field>
|
||||
<Field label="Default model">
|
||||
<Field label={t("Default model")}>
|
||||
<input className="input" name="model" defaultValue={provider.model ?? entry.defaultModel ?? ""} />
|
||||
</Field>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ProviderCatalogEntry, ProviderRecord, ProviderRequestRecord, Works
|
||||
import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { Button, Field, Icon, Pill, Segmented } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
interface ProviderRequestPanelProps {
|
||||
orgId: string;
|
||||
@@ -14,8 +15,8 @@ interface ProviderRequestPanelProps {
|
||||
}
|
||||
|
||||
const MODE_OPTIONS: ReadonlyArray<{ id: ProviderExecutionMode; label: string }> = [
|
||||
{ id: PROVIDER_EXECUTION_MODE.FOREGROUND, label: "Foreground" },
|
||||
{ id: PROVIDER_EXECUTION_MODE.BACKGROUND, label: "Background" },
|
||||
{ id: PROVIDER_EXECUTION_MODE.FOREGROUND, label: t("Foreground") },
|
||||
{ id: PROVIDER_EXECUTION_MODE.BACKGROUND, label: t("Background") },
|
||||
];
|
||||
|
||||
const ACTIVE_REQUEST_STATES = new Set<string>([
|
||||
@@ -64,13 +65,13 @@ export function ProviderRequestPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Requests</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Requests")}</div>
|
||||
<form className="card" style={{ marginBottom: 10 }} onSubmit={submit}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(150px, 1fr) minmax(150px, 1fr) minmax(200px, 1fr) auto", gap: 10, alignItems: "end" }}>
|
||||
<Field label="Provider">
|
||||
<Field label={t("Provider")}>
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Request provider"
|
||||
aria-label={t("Request provider")}
|
||||
value={selectedProvider?.id ?? ""}
|
||||
onChange={(event) => setProviderId(event.target.value)}
|
||||
>
|
||||
@@ -79,22 +80,22 @@ export function ProviderRequestPanel({
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Workspace">
|
||||
<Field label={t("Workspace")}>
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Request workspace"
|
||||
aria-label={t("Request workspace")}
|
||||
value={effectiveWorkspaceId}
|
||||
disabled={workspaces.length === 0}
|
||||
onChange={(event) => setWorkspaceId(event.target.value)}
|
||||
>
|
||||
{workspaces.length === 0 && <option value="">Organization</option>}
|
||||
{workspaces.length === 0 && <option value="">{t("Organization")}</option>}
|
||||
{workspaces.map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>{workspace.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="field">
|
||||
<span>Mode</span>
|
||||
<span>{t("Mode")}</span>
|
||||
<div style={{ alignSelf: "flex-start" }}>
|
||||
<Segmented
|
||||
value={selectedMode}
|
||||
@@ -106,21 +107,21 @@ export function ProviderRequestPanel({
|
||||
<Button type="submit" tone="primary">
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<Icon name="send" size={16} />
|
||||
Run
|
||||
{t("Run")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="Prompt">
|
||||
<textarea className="input" aria-label="Request prompt" name="prompt" required rows={3} style={{ resize: "vertical" }} />
|
||||
<Field label={t("Prompt")}>
|
||||
<textarea className="input" aria-label={t("Request prompt")} name="prompt" required rows={3} style={{ resize: "vertical" }} />
|
||||
</Field>
|
||||
<Field label="Instructions">
|
||||
<input className="input" aria-label="Request instructions" name="instructions" />
|
||||
<Field label={t("Instructions")}>
|
||||
<input className="input" aria-label={t("Request instructions")} name="instructions" />
|
||||
</Field>
|
||||
</form>
|
||||
{recentRequests.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: 28 }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: "minmax(120px, 1fr) minmax(120px, 1fr) 92px 100px minmax(120px, 1fr) 86px", minWidth: 760 }}>
|
||||
<div>Provider</div><div>Workspace</div><div>Mode</div><div>State</div><div>Output</div><div>Action</div>
|
||||
<div>{t("Provider")}</div><div>{t("Workspace")}</div><div>{t("Mode")}</div><div>{t("State")}</div><div>{t("Output")}</div><div>{t("Action")}</div>
|
||||
</div>
|
||||
{recentRequests.map((request, index) => (
|
||||
<ProviderRequestRow
|
||||
@@ -171,14 +172,14 @@ function ProviderRequestRow({
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{request.model}</div>
|
||||
</div>
|
||||
<div className="mono" style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{workspace?.name ?? "Organization"}</div>
|
||||
<div className="mono" style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{workspace?.name ?? t("Organization")}</div>
|
||||
<div className="mono" style={{ color: "var(--text-2)" }}>{request.executionMode}</div>
|
||||
<div><Pill tone={stateTone(request.state)}>{request.state}</Pill></div>
|
||||
<div className="mono" style={{ color: "var(--text-2)" }}>{request.outputCharCount} chars</div>
|
||||
<div className="mono" style={{ color: "var(--text-2)" }}>{t("{count} chars", { count: request.outputCharCount })}</div>
|
||||
<Button size="small" tone="ghost" onClick={cancel} disabled={!canCancel}>
|
||||
<span className="row" style={{ gap: 5 }}>
|
||||
<Icon name="x" size={12} />
|
||||
Cancel
|
||||
{t("Cancel")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ProviderCatalogEntry,
|
||||
@@ -56,11 +57,11 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Providers</h1>
|
||||
<h1 className="page-h1">{t("Providers")}</h1>
|
||||
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ margin: "16px 0 10px" }}>Connected</div>
|
||||
<div className="section-label" style={{ margin: "16px 0 10px" }}>{t("Connected")}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 28 }}>
|
||||
{connected.map((provider) => (
|
||||
<ProviderRow
|
||||
@@ -85,7 +86,7 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
|
||||
{available.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Available</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Available")}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 10 }}>
|
||||
{available.map((provider) => (
|
||||
<ProviderCard key={provider.id} provider={provider} onConnect={() => setActive(provider)} />
|
||||
@@ -95,9 +96,9 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
title={active ? `Connect ${active.label}` : "Connect provider"}
|
||||
title={active ? t("Connect {label}", { label: active.label }) : t("Connect provider")}
|
||||
open={Boolean(active)}
|
||||
submitLabel="Save"
|
||||
submitLabel={t("Save")}
|
||||
hideSubmit={activeIsOAuth}
|
||||
onClose={close}
|
||||
onSubmit={save}
|
||||
@@ -133,13 +134,13 @@ function ProviderPolicyTable({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Runtime policy</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Runtime policy")}</div>
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: 28 }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: POLICY_GRID, minWidth: 780 }}
|
||||
>
|
||||
<div>Provider</div><div>Scope</div><div>Data policy</div><div>Background</div><div>Monthly limit</div><div>Usage</div>
|
||||
<div>{t("Provider")}</div><div>{t("Scope")}</div><div>{t("Data policy")}</div><div>{t("Background")}</div><div>{t("Monthly limit")}</div><div>{t("Usage")}</div>
|
||||
</div>
|
||||
{providers.map((provider, index) => {
|
||||
const rateLimit = requireProviderRateLimit(provider, providerRateLimits);
|
||||
@@ -207,35 +208,35 @@ function ProviderPolicyRow({
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={`${provider.label} workspace scope`}
|
||||
aria-label={t("{label} workspace scope", { label: provider.label })}
|
||||
value={workspaceScope}
|
||||
onChange={(event) => setWorkspaceScope(event.target.value === "workspace" ? "workspace" : "organization")}
|
||||
>
|
||||
<option value="organization">Organization</option>
|
||||
<option value="workspace">Workspace</option>
|
||||
<option value="organization">{t("Organization")}</option>
|
||||
<option value="workspace">{t("Workspace")}</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={`${provider.label} data policy`}
|
||||
aria-label={t("{label} data policy", { label: provider.label })}
|
||||
value={dataPolicy}
|
||||
onChange={(event) => updateDataPolicy(event.target.value as ProviderRecord["dataPolicy"])}
|
||||
>
|
||||
<option value={PROVIDER_DATA_POLICY.STANDARD}>Standard retention</option>
|
||||
<option value={PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}>Zero data retention</option>
|
||||
<option value={PROVIDER_DATA_POLICY.STANDARD}>{t("Standard retention")}</option>
|
||||
<option value={PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}>{t("Zero data retention")}</option>
|
||||
</select>
|
||||
<label className="row" style={{ gap: 8, fontSize: 12, color: "var(--text-2)" }}>
|
||||
<input
|
||||
aria-label={`${provider.label} background execution`}
|
||||
aria-label={t("{label} background execution", { label: provider.label })}
|
||||
type="checkbox"
|
||||
checked={backgroundEnabled}
|
||||
disabled={dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}
|
||||
onChange={(event) => setBackgroundEnabled(event.target.checked)}
|
||||
/>
|
||||
Allow
|
||||
{t("Allow")}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={`${provider.label} monthly request limit`}
|
||||
aria-label={t("{label} monthly request limit", { label: provider.label })}
|
||||
type="number"
|
||||
min={1}
|
||||
value={monthlyRequestLimit}
|
||||
@@ -243,8 +244,8 @@ function ProviderPolicyRow({
|
||||
/>
|
||||
<div>
|
||||
<div className="num mono">{rateLimit.requestCount} / {rateLimit.monthlyRequestLimit}</div>
|
||||
<button type="button" className="button small" aria-label={`Save ${provider.label} policy`} onClick={savePolicy}>
|
||||
Save
|
||||
<button type="button" className="button small" aria-label={t("Save {label} policy", { label: provider.label })} onClick={savePolicy}>
|
||||
{t("Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,21 +290,21 @@ function ProviderRow({
|
||||
</div>
|
||||
<ProviderHealth provider={provider} />
|
||||
<div className="num mono" style={{ fontSize: 13, minWidth: 72, textAlign: "right" }}>
|
||||
{boundCount} {boundCount === 1 ? "agent" : "agents"}
|
||||
{boundCount === 1 ? t("{count} agent", { count: boundCount }) : t("{count} agents", { count: boundCount })}
|
||||
</div>
|
||||
<span className="reveal row" style={{ gap: 6 }}>
|
||||
<Button onClick={onCheck}>Check</Button>
|
||||
<Button onClick={onManage}>Manage</Button>
|
||||
<Button tone="ghost" onClick={onRevoke}>Revoke</Button>
|
||||
<Button onClick={onCheck}>{t("Check")}</Button>
|
||||
<Button onClick={onManage}>{t("Manage")}</Button>
|
||||
<Button tone="ghost" onClick={onRevoke}>{t("Revoke")}</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderHealth({ provider }: { provider: ProviderRecord }) {
|
||||
if (provider.healthState === "healthy") return <Pill tone="green">Healthy</Pill>;
|
||||
if (provider.healthState === "failed") return <Pill tone="red">Failed</Pill>;
|
||||
return <Pill>Unchecked</Pill>;
|
||||
if (provider.healthState === "healthy") return <Pill tone="green">{t("Healthy")}</Pill>;
|
||||
if (provider.healthState === "failed") return <Pill tone="red">{t("Failed")}</Pill>;
|
||||
return <Pill>{t("Unchecked")}</Pill>;
|
||||
}
|
||||
|
||||
function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
|
||||
@@ -328,16 +329,16 @@ function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onCon
|
||||
</div>
|
||||
</div>
|
||||
<Pill>{readableAuth(provider.authType)}</Pill>
|
||||
<Button size="small" onClick={onConnect}>Connect</Button>
|
||||
<Button size="small" onClick={onConnect}>{t("Connect")}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readableAuth(authType: ProviderRecord["authType"]): string {
|
||||
if (authType === "api_key") return "API key";
|
||||
if (authType === "oauth_pkce") return "OAuth";
|
||||
if (authType === "account_subscription") return "Account";
|
||||
if (authType === "cloud") return "Cloud";
|
||||
if (authType === "api_key") return t("API key");
|
||||
if (authType === "oauth_pkce") return t("OAuth");
|
||||
if (authType === "account_subscription") return t("Account");
|
||||
if (authType === "cloud") return t("Cloud");
|
||||
return authType;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Button, Field, Icon, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type {
|
||||
AgentRecord,
|
||||
SkillBindingRecord,
|
||||
@@ -46,9 +47,9 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
return (
|
||||
<div className="page reveal-row">
|
||||
<div className="row" style={{ marginBottom: 24 }}>
|
||||
<h1 className="page-h1" style={{ margin: 0 }}>Skills</h1>
|
||||
<h1 className="page-h1" style={{ margin: 0 }}>{t("Skills")}</h1>
|
||||
<div className="reveal" style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" onClick={() => setImportOpen(true)}><Icon name="plus" size={16} /> Import</Button>
|
||||
<Button tone="primary" onClick={() => setImportOpen(true)}><Icon name="plus" size={16} /> {t("Import")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +58,7 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) 132px" }}
|
||||
>
|
||||
<div>Skill</div><div>Version</div><div>Tests</div><div>Held by</div><div>Actions</div>
|
||||
<div>{t("Skill")}</div><div>{t("Version")}</div><div>{t("Tests")}</div><div>{t("Held by")}</div><div>{t("Actions")}</div>
|
||||
</div>
|
||||
{skills.map((skill, index) => {
|
||||
const last = index === skills.length - 1;
|
||||
@@ -82,16 +83,16 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ minWidth: 0 }}>
|
||||
<Button size="small" onClick={() => { if (latest && revisionTests.length > 0) runTests(latest.id); }} disabled={!latest || revisionTests.length === 0}>Run tests</Button>
|
||||
<Button size="small" onClick={() => { if (latest && revisionTests.length > 0) runTests(latest.id); }} disabled={!latest || revisionTests.length === 0}>{t("Run tests")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog title="Import skill" open={importOpen} submitLabel="Import" onClose={() => setImportOpen(false)} onSubmit={importSkill}>
|
||||
<Field label="Directory path"><input className="input" name="directoryPath" required /></Field>
|
||||
<Field label="Version (semver)"><input className="input" name="versionSemver" required defaultValue="1.0.0" /></Field>
|
||||
<Dialog title={t("Import skill")} open={importOpen} submitLabel={t("Import")} onClose={() => setImportOpen(false)} onSubmit={importSkill}>
|
||||
<Field label={t("Directory path")}><input className="input" name="directoryPath" required /></Field>
|
||||
<Field label={t("Version (semver)")}><input className="input" name="versionSemver" required defaultValue="1.0.0" /></Field>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
@@ -102,13 +103,13 @@ function agentName(agentId: string, agents: AgentRecord[]): string {
|
||||
}
|
||||
|
||||
function testSummary(tests: SkillTestRecord[]): string {
|
||||
if (tests.length === 0) return "No tests";
|
||||
if (tests.length === 0) return t("No tests");
|
||||
const completed = tests.filter((test) => test.lastResult);
|
||||
if (completed.length === 0) return "Never run";
|
||||
if (completed.length === 0) return t("Never run");
|
||||
const passed = completed.filter((test) => testStatus(test) === "passed").length;
|
||||
const failed = completed.length - passed;
|
||||
if (failed > 0) return `${failed} failed`;
|
||||
return `${passed}/${tests.length} passed`;
|
||||
if (failed > 0) return t("{n} failed", { n: failed });
|
||||
return t("{passed}/{total} passed", { passed, total: tests.length });
|
||||
}
|
||||
|
||||
function testStatus(test: SkillTestRecord): "passed" | "failed" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "../../../../shared/domain";
|
||||
import { Button, Field, Icon, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
@@ -57,41 +58,41 @@ export function WorkspacePanel({
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">General</h1>
|
||||
<h1 className="page-h1">{t("General")}</h1>
|
||||
|
||||
<div className="row" style={{ gap: 8, margin: "16px 0 24px" }}>
|
||||
<Pill tone="violet" dot>{organization.executionMode === "ai_ceo" ? "AI CEO" : "AI Co-Founder"}</Pill>
|
||||
{organization.autoApproveHires ? <Pill tone="green" dot>Auto-approve hires</Pill> : <Pill>Hires require approval</Pill>}
|
||||
{organization.reviewDeliverables ? <Pill tone="amber" dot>Reviews deliverables</Pill> : <Pill>Ships without review</Pill>}
|
||||
<Pill tone="violet" dot>{organization.executionMode === "ai_ceo" ? t("AI CEO") : t("AI Co-Founder")}</Pill>
|
||||
{organization.autoApproveHires ? <Pill tone="green" dot>{t("Auto-approve hires")}</Pill> : <Pill>{t("Hires require approval")}</Pill>}
|
||||
{organization.reviewDeliverables ? <Pill tone="amber" dot>{t("Reviews deliverables")}</Pill> : <Pill>{t("Ships without review")}</Pill>}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<KeyValue label="Name" value={organization.name} />
|
||||
<KeyValue label="Mission" value={organization.mission || "—"} />
|
||||
<KeyValue label="Goal" value={organization.goal || "—"} />
|
||||
<KeyValue label="Description" value={organization.description || "—"} />
|
||||
<KeyValue label={t("Name")} value={organization.name} />
|
||||
<KeyValue label={t("Mission")} value={organization.mission || "—"} />
|
||||
<KeyValue label={t("Goal")} value={organization.goal || "—"} />
|
||||
<KeyValue label={t("Description")} value={organization.description || "—"} />
|
||||
</div>
|
||||
|
||||
<div className="section-label" style={{ marginBottom: 8 }}>Appearance</div>
|
||||
<div className="section-label" style={{ marginBottom: 8 }}>{t("Appearance")}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 240px))", gap: 12, marginBottom: 28 }}>
|
||||
<Field label="Theme">
|
||||
<Field label={t("Theme")}>
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Theme"
|
||||
aria-label={t("Theme")}
|
||||
value={uiPreferences.theme}
|
||||
onChange={(event) =>
|
||||
void actions.run(() => window.agentCompany.setUiPreferences({ theme: event.target.value as UiTheme }))
|
||||
}
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="system">{t("System")}</option>
|
||||
<option value="light">{t("Light")}</option>
|
||||
<option value="dark">{t("Dark")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Language">
|
||||
<Field label={t("Language")}>
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Language"
|
||||
aria-label={t("Language")}
|
||||
value={uiPreferences.language}
|
||||
onChange={(event) =>
|
||||
void actions.run(() => window.agentCompany.setUiPreferences({ language: event.target.value as UiLanguage }))
|
||||
@@ -104,9 +105,9 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
|
||||
<div className="row reveal-row" style={{ marginBottom: 8 }}>
|
||||
<div className="section-label">Workspaces</div>
|
||||
<div className="section-label">{t("Workspaces")}</div>
|
||||
<span className="reveal" style={{ marginLeft: "auto" }}>
|
||||
<Button tone="ghost" onClick={() => setCreateOpen(true)}><Icon name="plus" size={16} /> Add</Button>
|
||||
<Button tone="ghost" onClick={() => setCreateOpen(true)}><Icon name="plus" size={16} /> {t("Add")}</Button>
|
||||
</span>
|
||||
</div>
|
||||
{workspaces.map((workspace) => (
|
||||
@@ -116,27 +117,27 @@ export function WorkspacePanel({
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{workspace.localPath}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
{workspace.isPrimary && <Pill tone="violet">Primary</Pill>}
|
||||
{workspace.isPrimary && <Pill tone="violet">{t("Primary")}</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Dialog title="Add workspace" open={createOpen} submitLabel="Create" onClose={() => setCreateOpen(false)} onSubmit={submit}>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Local path">
|
||||
<Dialog title={t("Add workspace")} open={createOpen} submitLabel={t("Create")} onClose={() => setCreateOpen(false)} onSubmit={submit}>
|
||||
<Field label={t("Name")}><input className="input" name="name" required /></Field>
|
||||
<Field label={t("Local path")}>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<input className="input" name="localPath" required style={{ flex: 1 }} />
|
||||
<Button tone="ghost" onClick={() => {
|
||||
const form = document.querySelector<HTMLFormElement>("form:has([name='localPath'])");
|
||||
if (form) void chooseDirectory(form);
|
||||
}}>
|
||||
<Icon name="folder" size={16} /> Browse
|
||||
<Icon name="folder" size={16} /> {t("Browse")}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Project">
|
||||
<Field label={t("Project")}>
|
||||
<select className="input" name="projectId" defaultValue="">
|
||||
<option value="">None</option>
|
||||
<option value="">{t("None")}</option>
|
||||
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
SocialAuditRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { formatRelative } from "../types";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string;
|
||||
@@ -37,7 +38,7 @@ export function SocialActivity({
|
||||
return account ? account.handle : "—";
|
||||
};
|
||||
const employeeName = (id: string | null): string => {
|
||||
if (!id) return "System";
|
||||
if (!id) return t("System");
|
||||
return employees.find((item) => item.id === id)?.displayName ?? id;
|
||||
};
|
||||
|
||||
@@ -68,7 +69,7 @@ export function SocialActivity({
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Access & activity</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Access & activity")}</div>
|
||||
{grants.length > 0 && (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 12 }}>
|
||||
{grants.map((grant, index) => (
|
||||
@@ -99,7 +100,7 @@ export function SocialActivity({
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{accountHandle(entry.accountId)}</span>
|
||||
<Pill tone={policyTone(entry.policyDecision)}>{humanize(entry.policyDecision)}</Pill>
|
||||
<span style={{ marginLeft: "auto", fontSize: 12, color: "var(--text-3)" }}>
|
||||
{entry.actorType === "agent" ? "agent" : employeeName(entry.actorId)} · {formatRelative(entry.createdAt)}
|
||||
{entry.actorType === "agent" ? t("agent") : employeeName(entry.actorId)} · {formatRelative(entry.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -129,10 +130,10 @@ function scopeSummary(scopeJson: string): string {
|
||||
const actions = (parsed as { actions: unknown[] }).actions.filter((item): item is string => typeof item === "string");
|
||||
const ceiling = (parsed as { riskCeiling?: unknown }).riskCeiling;
|
||||
const risk = typeof ceiling === "string" ? ` · ≤${ceiling}` : "";
|
||||
return `${actions.length} action${actions.length === 1 ? "" : "s"}${risk}`;
|
||||
return `${actions.length === 1 ? t("{count} action", { count: actions.length }) : t("{count} actions", { count: actions.length })}${risk}`;
|
||||
}
|
||||
} catch {
|
||||
return "scoped";
|
||||
return t("scoped");
|
||||
}
|
||||
return "scoped";
|
||||
return t("scoped");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { IconButton } from "../../shared/ui";
|
||||
import type { ScheduledPostRecord, SocialAccountRecord, SocialPlatform } from "../../../../shared/domain";
|
||||
import { PLATFORM_COLOR_TOKENS } from "./platform-tokens";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
interface MonthAnchor {
|
||||
year: number;
|
||||
@@ -31,8 +32,8 @@ export function SocialCalendar({
|
||||
<div className="row" style={{ marginBottom: 14 }}>
|
||||
<div className="card-label">{monthLabel}</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 2 }}>
|
||||
<IconButton icon="chevronLeft" label="Previous month" onClick={() => setMonth(stepMonth({ year, month }, -1))} />
|
||||
<IconButton icon="chevron" label="Next month" onClick={() => setMonth(stepMonth({ year, month }, 1))} />
|
||||
<IconButton icon="chevronLeft" label={t("Previous month")} onClick={() => setMonth(stepMonth({ year, month }, -1))} />
|
||||
<IconButton icon="chevron" label={t("Next month")} onClick={() => setMonth(stepMonth({ year, month }, 1))} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4, marginBottom: 6 }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Avatar } from "../../shared/ui";
|
||||
import type { AgentRecord, ApprovalRecord } from "../../../../shared/domain";
|
||||
import { formatRelative } from "../types";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
interface PipelineStage {
|
||||
id: string;
|
||||
@@ -9,10 +10,10 @@ interface PipelineStage {
|
||||
}
|
||||
|
||||
const STAGES: PipelineStage[] = [
|
||||
{ id: "pending_approval", label: "Pending approval", color: "var(--amber)" },
|
||||
{ id: "approved", label: "Approved", color: "var(--green)" },
|
||||
{ id: "active", label: "Active", color: "var(--accent)" },
|
||||
{ id: "rejected", label: "Rejected", color: "var(--red)" },
|
||||
{ id: "pending_approval", label: t("Pending approval"), color: "var(--amber)" },
|
||||
{ id: "approved", label: t("Approved"), color: "var(--green)" },
|
||||
{ id: "active", label: t("Active"), color: "var(--accent)" },
|
||||
{ id: "rejected", label: t("Rejected"), color: "var(--red)" },
|
||||
];
|
||||
|
||||
interface PipelineEntry {
|
||||
@@ -37,7 +38,7 @@ export function HiringPipeline({ agents, approvals }: { agents: AgentRecord[]; a
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<div className="section-label" style={{ marginBottom: 12 }}>Hiring pipeline</div>
|
||||
<div className="section-label" style={{ marginBottom: 12 }}>{t("Hiring pipeline")}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12 }}>
|
||||
{STAGES.map((stage) => {
|
||||
const stageEntries = entries.filter((e) => e.stage === stage.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar } from "../../shared/ui";
|
||||
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const NODE_W = 208;
|
||||
const NODE_H = 64;
|
||||
@@ -104,9 +105,9 @@ export function OrgChartTree({
|
||||
return (
|
||||
<div className="org-chart">
|
||||
<div className="org-zoom">
|
||||
<button type="button" className="org-zoom-btn" aria-label="Zoom out" disabled={scale <= ZOOM_MIN} onClick={() => zoomTo(scale - ZOOM_STEP)}>−</button>
|
||||
<button type="button" className="org-zoom-label" aria-label="Reset zoom" onClick={() => zoomTo(1)}>{Math.round(scale * 100)}%</button>
|
||||
<button type="button" className="org-zoom-btn" aria-label="Zoom in" disabled={scale >= ZOOM_MAX} onClick={() => zoomTo(scale + ZOOM_STEP)}>+</button>
|
||||
<button type="button" className="org-zoom-btn" aria-label={t("Zoom out")} disabled={scale <= ZOOM_MIN} onClick={() => zoomTo(scale - ZOOM_STEP)}>−</button>
|
||||
<button type="button" className="org-zoom-label" aria-label={t("Reset zoom")} onClick={() => zoomTo(1)}>{Math.round(scale * 100)}%</button>
|
||||
<button type="button" className="org-zoom-btn" aria-label={t("Zoom in")} disabled={scale >= ZOOM_MAX} onClick={() => zoomTo(scale + ZOOM_STEP)}>+</button>
|
||||
</div>
|
||||
<div className="org-scroll" ref={scrollRef}>
|
||||
<div className="org-sizer" style={{ width: width * scale, height: height * scale }}>
|
||||
@@ -171,7 +172,7 @@ function buildLayout(
|
||||
? { id: boardChair.id, name: boardChair.displayName, role: boardChair.role, human: true, agentId: null, children: roots, x: 0, y: 0, width: 0 }
|
||||
: roots.length === 1
|
||||
? roots[0]
|
||||
: { id: "__org__", name: "Organization", role: "Reporting lines", human: false, agentId: null, children: roots, x: 0, y: 0, width: 0 };
|
||||
: { id: "__org__", name: t("Organization"), role: t("Reporting lines"), human: false, agentId: null, children: roots, x: 0, y: 0, width: 0 };
|
||||
|
||||
measure(root);
|
||||
let maxDepth = 0;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from "../../../shared/domain";
|
||||
import type { SnapshotActions } from "../app/useSnapshot";
|
||||
import type { RouteId } from "../shared/Shell";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
export interface PageProps {
|
||||
snapshot: AppSnapshot;
|
||||
@@ -68,13 +69,13 @@ export function formatRelative(iso: string, reference: Date = new Date()): strin
|
||||
if (Number.isNaN(target)) return "—";
|
||||
const diff = reference.getTime() - target;
|
||||
const minutes = Math.round(diff / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (minutes < 1) return t("just now");
|
||||
if (minutes < 60) return t("{n}m ago", { n: minutes });
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (hours < 24) return t("{n}h ago", { n: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 7) return t("{n}d ago", { n: days });
|
||||
const weeks = Math.round(days / 7);
|
||||
if (weeks < 5) return `${weeks}w ago`;
|
||||
if (weeks < 5) return t("{n}w ago", { n: weeks });
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Field } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import type { AgentRecord } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
@@ -37,12 +38,12 @@ export function GoalDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="New goal" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
|
||||
<Field label="Title"><input className="input" name="title" required /></Field>
|
||||
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||
<Field label="Owner agent">
|
||||
<Dialog title={t("New goal")} open={open} submitLabel={t("Create")} onClose={onClose} onSubmit={submit}>
|
||||
<Field label={t("Title")}><input className="input" name="title" required /></Field>
|
||||
<Field label={t("Description")}><textarea className="input" name="description" required /></Field>
|
||||
<Field label={t("Owner agent")}>
|
||||
<select className="input" name="ownerAgentId" defaultValue="">
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{agents.filter((agent) => agent.lifecycle === "active").map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Avatar, IconButton, Pill } from "../../shared/ui";
|
||||
import type { AgentRecord, ProjectRecord, ProviderRecord, TaskRecord } from "../../../../shared/domain";
|
||||
import { canRun, prettyStatus, providerFor } from "./task-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const GRID = "minmax(0, 3fr) 110px 96px minmax(0, 1.2fr) 88px";
|
||||
|
||||
@@ -22,7 +23,7 @@ export function ListView({
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: GRID }}>
|
||||
<div>Title</div><div>Status</div><div>Priority</div><div>Assignee</div><div />
|
||||
<div>{t("Title")}</div><div>{t("Status")}</div><div>{t("Priority")}</div><div>{t("Assignee")}</div><div />
|
||||
</div>
|
||||
{tasks.map((task, index) => {
|
||||
const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null;
|
||||
@@ -47,7 +48,7 @@ export function ListView({
|
||||
{project && <span style={{ flexShrink: 0 }}><Pill>{truncate(project.name, 24)}</Pill></span>}
|
||||
</div>
|
||||
<div><Pill>{prettyStatus(task.status)}</Pill></div>
|
||||
<div><Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority}</Pill></div>
|
||||
<div><Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority === "high" ? t("high") : task.priority === "low" ? t("low") : t("normal")}</Pill></div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{assignee ? (
|
||||
<div className="row" style={{ gap: 8, minWidth: 0 }}>
|
||||
@@ -61,8 +62,8 @@ export function ListView({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }} onClick={(event) => event.stopPropagation()}>
|
||||
{assignee && <IconButton icon="send" label={`Run ${task.title}`} onClick={() => onRun(task)} disabled={!canRun(provider)} />}
|
||||
<IconButton icon="more" label={`Open ${task.title}`} onClick={() => onOpen(task)} />
|
||||
{assignee && <IconButton icon="send" label={t("Run {title}", { title: task.title })} onClick={() => onRun(task)} disabled={!canRun(provider)} />}
|
||||
<IconButton icon="more" label={t("Open {title}", { title: task.title })} onClick={() => onOpen(task)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Field } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import type { AgentRecord, GoalRecord } from "../../../../shared/domain";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
function text(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? "").trim();
|
||||
@@ -40,18 +41,18 @@ export function ProjectDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="New project" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||
<Field label="Goal">
|
||||
<Dialog title={t("New project")} open={open} submitLabel={t("Create")} onClose={onClose} onSubmit={submit}>
|
||||
<Field label={t("Name")}><input className="input" name="name" required /></Field>
|
||||
<Field label={t("Description")}><textarea className="input" name="description" required /></Field>
|
||||
<Field label={t("Goal")}>
|
||||
<select className="input" name="goalId" defaultValue="">
|
||||
<option value="">None</option>
|
||||
<option value="">{t("None")}</option>
|
||||
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Lead agent">
|
||||
<Field label={t("Lead agent")}>
|
||||
<select className="input" name="leadAgentId" defaultValue="">
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Avatar, Pill } from "../../shared/ui";
|
||||
import { TASK_STATUS, type AgentRecord, type ChatSpaceRecord, type ProjectRecord, type TaskRecord, type WorkspaceRecord } from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function ProjectOverview({
|
||||
projects,
|
||||
@@ -17,7 +18,7 @@ export function ProjectOverview({
|
||||
if (projects.length === 0) return null;
|
||||
return (
|
||||
<section style={{ marginBottom: 18 }}>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>Projects</div>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Projects")}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 10 }}>
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
@@ -55,7 +56,7 @@ function ProjectCard({
|
||||
<div style={{ fontWeight: 560, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{project.name}</div>
|
||||
<div className="row" style={{ marginTop: 8, gap: 6, flexWrap: "wrap" }}>
|
||||
<Pill>{project.status.replace(/_/g, " ")}</Pill>
|
||||
<Pill>{done} / {tasks.length} tasks</Pill>
|
||||
<Pill>{t("{done} / {total} tasks", { done, total: tasks.length })}</Pill>
|
||||
{workspace && <Pill>{workspace.name}</Pill>}
|
||||
{room && <Pill>#{room.name}</Pill>}
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,13 @@ import { TASK_STATUS } from "../../../../shared/domain";
|
||||
import { auditEventLabel, formatRelative } from "../types";
|
||||
import { canRun, nextStatus, prettyRunState, prettyStatus, providerFor, runsForTask, runTone } from "./task-helpers";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
type Tab = "overview" | "subtasks" | "activity";
|
||||
const TABS: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "subtasks", label: "Subtasks" },
|
||||
{ id: "activity", label: "Activity" },
|
||||
{ id: "overview", label: t("Overview") },
|
||||
{ id: "subtasks", label: t("Subtasks") },
|
||||
{ id: "activity", label: t("Activity") },
|
||||
];
|
||||
|
||||
export function TaskDetailDialog({
|
||||
@@ -98,14 +99,14 @@ export function TaskDetailDialog({
|
||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 8 }}>
|
||||
{task.priority === "high" && <span style={{ width: 3, height: 18, background: "var(--red)", borderRadius: 2, marginTop: 2, flexShrink: 0 }} />}
|
||||
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{task.title}</h2>
|
||||
<IconButton icon="edit" label="Edit" onClick={() => setEditing(true)} />
|
||||
<IconButton icon="x" label="Close" onClick={onClose} />
|
||||
<IconButton icon="edit" label={t("Edit")} onClick={() => setEditing(true)} />
|
||||
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
|
||||
<Pill>{prettyStatus(task.status)}</Pill>
|
||||
<Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority}</Pill>
|
||||
{task.risk === "high" && <Pill tone="red">high risk</Pill>}
|
||||
{task.requiresFilesystem && <Pill>workspace</Pill>}
|
||||
<Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority === "high" ? t("high") : task.priority === "low" ? t("low") : t("normal")}</Pill>
|
||||
{task.risk === "high" && <Pill tone="red">{t("high risk")}</Pill>}
|
||||
{task.requiresFilesystem && <Pill>{t("workspace")}</Pill>}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}><Segmented value={tab} onChange={setTab} options={TABS} /></div>
|
||||
@@ -121,8 +122,8 @@ export function TaskDetailDialog({
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<Button tone="ghost" onClick={deleteTask}><Icon name="x" size={16} /> Delete</Button>
|
||||
{assignee && <Button onClick={() => onRun(task)} disabled={!canRun(provider)}><Icon name="send" size={16} /> Run</Button>}
|
||||
<Button tone="ghost" onClick={deleteTask}><Icon name="x" size={16} /> {t("Delete")}</Button>
|
||||
{assignee && <Button onClick={() => onRun(task)} disabled={!canRun(provider)}><Icon name="send" size={16} /> {t("Run")}</Button>}
|
||||
{advanceTo && <Button tone="primary" onClick={() => onAdvance(task)}><Icon name="check" size={16} /> {prettyStatus(advanceTo)}</Button>}
|
||||
</div>
|
||||
|
||||
@@ -143,17 +144,17 @@ function OverviewTab({ task, assignee, project, goal, workspace, provider, runs
|
||||
<p style={{ margin: "0 0 14px", color: "var(--text-2)", fontSize: 14, lineHeight: 1.55, whiteSpace: "pre-wrap" }}>{task.description}</p>
|
||||
)}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "8px 14px", fontSize: 12 }}>
|
||||
<MetaLabel>Assignee</MetaLabel>
|
||||
<MetaLabel>{t("Assignee")}</MetaLabel>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
{assignee ? (<><Avatar name={assignee.name} size="sm" bot /><span>{assignee.name}</span>{provider && <span style={{ color: "var(--text-3)" }}>({provider.label})</span>}</>) : <span style={{ color: "var(--text-4)" }}>Unassigned</span>}
|
||||
{assignee ? (<><Avatar name={assignee.name} size="sm" bot /><span>{assignee.name}</span>{provider && <span style={{ color: "var(--text-3)" }}>({provider.label})</span>}</>) : <span style={{ color: "var(--text-4)" }}>{t("Unassigned")}</span>}
|
||||
</div>
|
||||
{project && (<><MetaLabel>Project</MetaLabel><div>{project.name}</div></>)}
|
||||
{goal && (<><MetaLabel>Goal</MetaLabel><div>{goal.title}</div></>)}
|
||||
{workspace && (<><MetaLabel>Workspace</MetaLabel><div>{workspace.name}</div></>)}
|
||||
{project && (<><MetaLabel>{t("Project")}</MetaLabel><div>{project.name}</div></>)}
|
||||
{goal && (<><MetaLabel>{t("Goal")}</MetaLabel><div>{goal.title}</div></>)}
|
||||
{workspace && (<><MetaLabel>{t("Workspace")}</MetaLabel><div>{workspace.name}</div></>)}
|
||||
</div>
|
||||
{runs.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="section-label">Runs</div>
|
||||
<div className="section-label">{t("Runs")}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
@@ -177,7 +178,7 @@ function OverviewTab({ task, assignee, project, goal, workspace, provider, runs
|
||||
function SubtasksTab({ subtasks, agents }: { subtasks: TaskRecord[]; agents: AgentRecord[] }) {
|
||||
return (
|
||||
<div style={{ minHeight: 60 }}>
|
||||
{subtasks.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No subtasks</div>}
|
||||
{subtasks.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>{t("No subtasks")}</div>}
|
||||
{subtasks.map((st) => {
|
||||
const owner = agents.find((a) => a.id === st.assigneeAgentId);
|
||||
return (
|
||||
@@ -198,7 +199,7 @@ function ActivityTab({ audit, approvals }: { audit: AuditEventRecord[]; approval
|
||||
<div style={{ minHeight: 60 }}>
|
||||
{approvals.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div className="section-label">Approvals</div>
|
||||
<div className="section-label">{t("Approvals")}</div>
|
||||
{approvals.map((a) => (
|
||||
<div key={a.id} className="row" style={{ gap: 8, padding: "6px 0" }}>
|
||||
<Pill tone={a.state === "approved" ? "green" : a.state === "pending" ? "amber" : "red"}>{a.state}</Pill>
|
||||
@@ -208,8 +209,8 @@ function ActivityTab({ audit, approvals }: { audit: AuditEventRecord[]; approval
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="section-label">Events</div>
|
||||
{audit.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No events</div>}
|
||||
<div className="section-label">{t("Events")}</div>
|
||||
{audit.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>{t("No events")}</div>}
|
||||
{audit.map((e) => (
|
||||
<div key={e.id} className="row" style={{ gap: 8, padding: "5px 0" }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(e.createdAt)}</span>
|
||||
@@ -228,36 +229,36 @@ function EditOverlay({ task, agents, allTasks, onSubmit, onCancel }: {
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, background: "var(--bg)", borderRadius: 14, padding: 24, overflow: "auto", zIndex: 2 }}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<h3 style={{ margin: "0 0 14px", fontSize: 15, fontWeight: 600 }}>Edit task</h3>
|
||||
<Field label="Title"><input className="input" name="title" defaultValue={task.title} required /></Field>
|
||||
<Field label="Description"><textarea className="input" name="description" rows={4} defaultValue={task.description} /></Field>
|
||||
<Field label="Assignee">
|
||||
<h3 style={{ margin: "0 0 14px", fontSize: 15, fontWeight: 600 }}>{t("Edit task")}</h3>
|
||||
<Field label={t("Title")}><input className="input" name="title" defaultValue={task.title} required /></Field>
|
||||
<Field label={t("Description")}><textarea className="input" name="description" rows={4} defaultValue={task.description} /></Field>
|
||||
<Field label={t("Assignee")}>
|
||||
<select className="input" name="assigneeAgentId" defaultValue={task.assigneeAgentId ?? ""}>
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{agents.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<Field label="Risk">
|
||||
<Field label={t("Risk")}>
|
||||
<select className="input" name="risk" defaultValue={task.risk}>
|
||||
<option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option>
|
||||
<option value="low">{t("Low")}</option><option value="medium">{t("Medium")}</option><option value="high">{t("High")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Priority">
|
||||
<Field label={t("Priority")}>
|
||||
<select className="input" name="priority" defaultValue={task.priority}>
|
||||
<option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option>
|
||||
<option value="low">{t("Low")}</option><option value="normal">{t("Normal")}</option><option value="high">{t("High")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Parent task">
|
||||
<Field label={t("Parent task")}>
|
||||
<select className="input" name="parentTaskId" defaultValue={task.parentTaskId ?? ""}>
|
||||
<option value="">None</option>
|
||||
<option value="">{t("None")}</option>
|
||||
{otherTasks.map((t) => <option key={t.id} value={t.id}>{t.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button tone="primary" type="submit">Save</Button>
|
||||
<Button onClick={onCancel}>{t("Cancel")}</Button>
|
||||
<Button tone="primary" type="submit">{t("Save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { TASK_STATUS, type GoalRecord, type ProjectRecord, type TaskRecord, type TaskStatus } from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const SEGMENTS: ReadonlyArray<{ id: TaskStatus; label: string; tone: string }> = [
|
||||
{ id: TASK_STATUS.DONE, label: "Done", tone: "var(--green)" },
|
||||
{ id: TASK_STATUS.REVIEW, label: "In review", tone: "var(--amber)" },
|
||||
{ id: TASK_STATUS.IN_PROGRESS, label: "In progress", tone: "var(--accent)" },
|
||||
{ id: TASK_STATUS.BACKLOG, label: "Backlog", tone: "var(--panel-3)" },
|
||||
{ id: TASK_STATUS.DONE, label: t("Done"), tone: "var(--green)" },
|
||||
{ id: TASK_STATUS.REVIEW, label: t("In review"), tone: "var(--amber)" },
|
||||
{ id: TASK_STATUS.IN_PROGRESS, label: t("In progress"), tone: "var(--accent)" },
|
||||
{ id: TASK_STATUS.BACKLOG, label: t("Backlog"), tone: "var(--panel-3)" },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ export function TimelineView({ tasks, goals, projects }: { tasks: TaskRecord[];
|
||||
{unlinked.length > 0 && (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<span style={{ fontWeight: 560, fontSize: 14, color: "var(--text-2)", flex: 1 }}>Unlinked tasks</span>
|
||||
<span style={{ fontWeight: 560, fontSize: 14, color: "var(--text-2)", flex: 1 }}>{t("Unlinked tasks")}</span>
|
||||
<CompletionReadout tasks={unlinked} />
|
||||
</div>
|
||||
<StatusBar tasks={unlinked} height={6} style={{ marginTop: 12 }} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type TaskRunRecord,
|
||||
type TaskStatus,
|
||||
} from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function nextStatus(status: TaskStatus): TaskStatus | null {
|
||||
if (status === TASK_STATUS.BACKLOG) return TASK_STATUS.IN_PROGRESS;
|
||||
@@ -14,10 +15,17 @@ export function nextStatus(status: TaskStatus): TaskStatus | null {
|
||||
}
|
||||
|
||||
export function prettyStatus(status: TaskStatus): string {
|
||||
return status.replaceAll("_", " ");
|
||||
if (status === TASK_STATUS.BACKLOG) return t("backlog");
|
||||
if (status === TASK_STATUS.IN_PROGRESS) return t("in progress");
|
||||
if (status === TASK_STATUS.REVIEW) return t("review");
|
||||
return t("done");
|
||||
}
|
||||
|
||||
export function prettyRunState(state: string): string {
|
||||
if (state === "queued") return t("queued");
|
||||
if (state === "running") return t("running");
|
||||
if (state === "completed") return t("completed");
|
||||
if (state === "failed") return t("failed");
|
||||
return state.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { Button } from "./ui";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
export function Dialog({
|
||||
title,
|
||||
@@ -25,7 +26,7 @@ export function Dialog({
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<div style={{ display: "grid", gap: 12 }}>{children}</div>
|
||||
<div className="modal-actions">
|
||||
<Button tone="ghost" onClick={onClose}>{hideSubmit ? "Close" : "Cancel"}</Button>
|
||||
<Button tone="ghost" onClick={onClose}>{hideSubmit ? t("Close") : t("Cancel")}</Button>
|
||||
{!hideSubmit && <Button tone="primary" type="submit">{submitLabel}</Button>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { AppSnapshot, OrganizationRecord } from "../../../shared/domain";
|
||||
import { Icon } from "./Icon";
|
||||
import { CascadeMenu, CascadeRow } from "./CascadeMenu";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
function orgInitial(name: string): string {
|
||||
return name.trim().charAt(0).toUpperCase();
|
||||
@@ -54,7 +55,7 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete }
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-org"
|
||||
aria-label={`${active.name} — switch organization`}
|
||||
aria-label={t("{name} — switch organization", { name: active.name })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
@@ -66,17 +67,17 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete }
|
||||
{open && (confirmingOrg ? (
|
||||
<div role="menu" className="org-menu">
|
||||
<div className="org-menu-confirm">
|
||||
<div className="org-menu-confirm-title">Delete “{confirmingOrg.name}”?</div>
|
||||
<div className="org-menu-confirm-hint">All agents, work, chats, and files are permanently removed.</div>
|
||||
<div className="org-menu-confirm-title">{t("Delete “{name}”?", { name: confirmingOrg.name })}</div>
|
||||
<div className="org-menu-confirm-hint">{t("All agents, work, chats, and files are permanently removed.")}</div>
|
||||
<div className="org-menu-confirm-actions">
|
||||
<button type="button" className="org-menu-confirm-no" onClick={() => setConfirmingDelete(null)}>Cancel</button>
|
||||
<button type="button" className="org-menu-confirm-yes" onClick={() => handleDelete(confirmingOrg.id)}>Delete</button>
|
||||
<button type="button" className="org-menu-confirm-no" onClick={() => setConfirmingDelete(null)}>{t("Cancel")}</button>
|
||||
<button type="button" className="org-menu-confirm-yes" onClick={() => handleDelete(confirmingOrg.id)}>{t("Delete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CascadeMenu className="org-menu">
|
||||
<div className="org-menu-section-label">Organizations</div>
|
||||
<div className="org-menu-section-label">{t("Organizations")}</div>
|
||||
{orgs.map((org) => (
|
||||
<CascadeRow
|
||||
key={org.id}
|
||||
@@ -111,7 +112,7 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete }
|
||||
onClick={() => { setOpen(false); setConfirmingDelete(null); onNew(); }}
|
||||
>
|
||||
<span className="icon-box" style={{ width: 18, height: 18 }}><Icon name="plus" size={16} color="var(--text-2)" /></span>
|
||||
<span>New organization</span>
|
||||
<span>{t("New organization")}</span>
|
||||
</button>
|
||||
</CascadeMenu>
|
||||
))}
|
||||
@@ -136,12 +137,12 @@ function OrgActions({
|
||||
{!isActive && (
|
||||
<button type="button" role="menuitem" className="org-menu-action" onClick={onSwitch}>
|
||||
<span className="icon-box" style={{ width: 18, height: 18 }}><Icon name="arrowRight" size={16} color="var(--text-2)" /></span>
|
||||
<span>Switch to this org</span>
|
||||
<span>{t("Switch to this org")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" role="menuitem" className="org-menu-action danger" onClick={onDelete}>
|
||||
<span className="icon-box" style={{ width: 18, height: 18 }}><Icon name="x" size={16} color="var(--red)" /></span>
|
||||
<span>Delete organization</span>
|
||||
<span>{t("Delete organization")}</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,23 +3,24 @@ import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../..
|
||||
import { SEARCH_ENTITY_TYPES } from "../../../shared/domain";
|
||||
import type { RouteId } from "./Shell";
|
||||
import { Button, Icon, Pill } from "./ui";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
type SearchFilter = "all" | SearchEntityType;
|
||||
|
||||
const LABELS: Record<SearchEntityType, string> = {
|
||||
employee: "Employee",
|
||||
agent: "Agent",
|
||||
task: "Task",
|
||||
goal: "Goal",
|
||||
project: "Project",
|
||||
workspace: "Workspace",
|
||||
approval: "Approval",
|
||||
chat_space: "Space",
|
||||
chat_message: "Message",
|
||||
file: "File",
|
||||
provider: "Provider",
|
||||
social_account: "Social",
|
||||
scheduled_post: "Post",
|
||||
employee: t("Employee"),
|
||||
agent: t("Agent"),
|
||||
task: t("Task"),
|
||||
goal: t("Goal"),
|
||||
project: t("Project"),
|
||||
workspace: t("Workspace"),
|
||||
approval: t("Approval"),
|
||||
chat_space: t("Space"),
|
||||
chat_message: t("Message"),
|
||||
file: t("File"),
|
||||
provider: t("Provider"),
|
||||
social_account: t("Social"),
|
||||
scheduled_post: t("Post"),
|
||||
};
|
||||
|
||||
export function SearchDialog({
|
||||
@@ -80,28 +81,28 @@ export function SearchDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label="Search" style={overlayStyle}>
|
||||
<div role="dialog" aria-modal="true" aria-label={t("Search")} style={overlayStyle}>
|
||||
<form onSubmit={submit} style={panelStyle}>
|
||||
<div className="row" style={{ padding: "10px 12px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<Icon name="search" size={14} color="var(--text-3)" />
|
||||
<input
|
||||
autoFocus
|
||||
aria-label="Search query"
|
||||
aria-label={t("Search query")}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<select
|
||||
aria-label="Search filter"
|
||||
aria-label={t("Search filter")}
|
||||
className="input"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value as SearchFilter)}
|
||||
style={{ width: 150, height: 28, padding: "2px 8px", fontSize: 12 }}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="all">{t("All")}</option>
|
||||
{SEARCH_ENTITY_TYPES.map((type) => <option key={type} value={type}>{LABELS[type]}</option>)}
|
||||
</select>
|
||||
<Button size="small" onClick={onClose}>Close</Button>
|
||||
<Button size="small" onClick={onClose}>{t("Close")}</Button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 420, overflow: "auto", padding: 8 }}>
|
||||
{visibleResults.map((result) => (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NavIcon } from "./NavIcon";
|
||||
import { OrgSwitcher } from "./OrgSwitcher";
|
||||
import { SearchDialog } from "./SearchDialog";
|
||||
import { TopbarSlotContext } from "./topbar-context";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
export type RouteId =
|
||||
| "home"
|
||||
@@ -28,15 +29,15 @@ interface NavSpec {
|
||||
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "runs", "office"]);
|
||||
|
||||
const allNav: NavSpec[] = [
|
||||
{ id: "home", label: "Home" },
|
||||
{ id: "work", label: "Work" },
|
||||
{ id: "team", label: "Team" },
|
||||
{ id: "office", label: "Office" },
|
||||
{ id: "chat", label: "Messages" },
|
||||
{ id: "runs", label: "Runs" },
|
||||
{ id: "files", label: "Files" },
|
||||
{ id: "approvals", label: "Approvals" },
|
||||
{ id: "connectors", label: "Connectors" },
|
||||
{ id: "home", label: t("Home") },
|
||||
{ id: "work", label: t("Work") },
|
||||
{ id: "team", label: t("Team") },
|
||||
{ id: "office", label: t("Office") },
|
||||
{ id: "chat", label: t("Messages") },
|
||||
{ id: "runs", label: t("Runs") },
|
||||
{ id: "files", label: t("Files") },
|
||||
{ id: "approvals", label: t("Approvals") },
|
||||
{ id: "connectors", label: t("Connectors") },
|
||||
];
|
||||
|
||||
export function Shell({
|
||||
@@ -88,9 +89,9 @@ export function Shell({
|
||||
onNew={onNewOrg}
|
||||
onDelete={onDeleteOrg}
|
||||
/>
|
||||
<button type="button" className="search-bar" aria-label="Search" onClick={() => setSearchOpen(true)}>
|
||||
<button type="button" className="search-bar" aria-label={t("Search")} onClick={() => setSearchOpen(true)}>
|
||||
<Icon name="search" size={13} />
|
||||
<span>Search</span>
|
||||
<span>{t("Search")}</span>
|
||||
<span className="search-kbd">⌘K</span>
|
||||
</button>
|
||||
<nav className="nav">
|
||||
@@ -102,13 +103,13 @@ export function Shell({
|
||||
<nav className="nav">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Settings"
|
||||
aria-label={t("Settings")}
|
||||
aria-current={route === "settings" ? "page" : undefined}
|
||||
className={`nav-item ${route === "settings" ? "active" : ""}`.trim()}
|
||||
onClick={() => setRoute("settings")}
|
||||
>
|
||||
<span className="icon-box"><NavIcon route="settings" /></span>
|
||||
<span>Settings</span>
|
||||
<span>{t("Settings")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
@@ -125,9 +126,9 @@ export function Shell({
|
||||
<span className="topbar-actions-slot" ref={setActionsSlot} />
|
||||
{!isMac && (
|
||||
<span className="window-controls">
|
||||
<IconButton icon="minus" label="Minimize" onClick={() => void window.agentCompany.windowMinimize()} />
|
||||
<IconButton icon="grid" label="Maximize" onClick={() => void window.agentCompany.windowMaximize()} />
|
||||
<IconButton icon="x" label="Close" onClick={() => void window.agentCompany.windowClose()} />
|
||||
<IconButton icon="minus" label={t("Minimize")} onClick={() => void window.agentCompany.windowMinimize()} />
|
||||
<IconButton icon="grid" label={t("Maximize")} onClick={() => void window.agentCompany.windowMaximize()} />
|
||||
<IconButton icon="x" label={t("Close")} onClick={() => void window.agentCompany.windowClose()} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,7 +179,7 @@ function NavButton({
|
||||
function labelFor(route: RouteId): string {
|
||||
const spec = allNav.find((item) => item.id === route);
|
||||
if (spec) return spec.label;
|
||||
if (route === "settings") return "Settings";
|
||||
if (route === "settings") return t("Settings");
|
||||
throw new Error(`Unknown route: ${route}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ export interface DesktopApi {
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
language: "en" | "zh";
|
||||
windowMinimize(): Promise<void>;
|
||||
windowMaximize(): Promise<void>;
|
||||
windowClose(): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { zh } from "../../src/renderer/src/i18n/zh";
|
||||
|
||||
const RENDERER_ROOT = join(__dirname, "../../src/renderer/src");
|
||||
|
||||
function rendererSources(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...rendererSources(path));
|
||||
else if (/\.(ts|tsx)$/.test(entry.name)) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("i18n coverage", () => {
|
||||
const sources = rendererSources(RENDERER_ROOT);
|
||||
|
||||
test("every t() key has a Chinese translation", () => {
|
||||
const missing = new Set<string>();
|
||||
for (const path of sources) {
|
||||
const src = readFileSync(path, "utf8");
|
||||
for (const match of src.matchAll(/\bt\(\s*"((?:[^"\\]|\\.)*)"/g)) {
|
||||
if (!(match[1] in zh)) missing.add(match[1]);
|
||||
}
|
||||
}
|
||||
expect([...missing].sort()).toEqual([]);
|
||||
});
|
||||
|
||||
test("t() keys are plain double-quoted literals", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const path of sources) {
|
||||
if (path.includes("/i18n/") || path.includes("\\i18n\\")) continue;
|
||||
const src = readFileSync(path, "utf8");
|
||||
// The key must be the literal first argument — expressions (ternaries,
|
||||
// variables, other quotes) hide keys from extraction and translation.
|
||||
if (/\bt\(\s*(?!")/.test(src)) offenders.push(path.slice(RENDERER_ROOT.length + 1));
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test("translations keep their placeholders", () => {
|
||||
const broken: string[] = [];
|
||||
for (const [key, value] of Object.entries(zh)) {
|
||||
const wanted = [...key.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
|
||||
const got = [...value.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
|
||||
if (wanted.join(",") !== got.join(",")) broken.push(key);
|
||||
}
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user