fix(web): externalize copy the jsx-only i18n guard cannot see, and gate it (#2711)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -104,11 +104,15 @@ never call `t()` at module scope (it freezes at the boot locale and the
|
|||||||
pseudo-locale cannot detect it); never pass an English plural ending as a value —
|
pseudo-locale cannot detect it); never pass an English plural ending as a value —
|
||||||
use `count` with `_one`/`_other`. `pnpm run i18n:check` enforces the last two.
|
use `count` with `_one`/`_other`. `pnpm run i18n:check` enforces the last two.
|
||||||
|
|
||||||
A clean lint is not proof a file is done. The rule only sees literals in JSX, and
|
A clean lint is not proof a file is done: the rule only sees literals in JSX. The
|
||||||
it **skips anything assigned to a SCREAMING_CASE identifier** — so a
|
positions it cannot inspect — SCREAMING_CASE config tables, plain `.ts` helpers,
|
||||||
`const ROWS = [{ label: "Disk usage" }]` config table passes silently. Review
|
parameter defaults, toast and setter arguments — are gated by
|
||||||
those by eye. The pseudo-locale (Settings → General → Appearance, dev/e2e builds)
|
`scripts/check-nonjsx-copy.mjs`, which runs inside `pnpm run i18n:check` and the
|
||||||
is the completeness check. Full guide: [`docs/i18n.md`](docs/i18n.md).
|
new-code ratchet. Mark a genuine non-string (a persisted value, an agent-facing
|
||||||
|
prompt, a `===` token) with `// i18n-exempt: <reason>`; an unexplained marker
|
||||||
|
fails. The pseudo-locale (Settings → General → Appearance, dev/e2e builds) is
|
||||||
|
still the completeness check for copy no literal scan can see. Full guide:
|
||||||
|
[`docs/i18n.md`](docs/i18n.md).
|
||||||
|
|
||||||
UI punctuation is plain and intentional: do not use the Unicode em dash (U+2014)
|
UI punctuation is plain and intentional: do not use the Unicode em dash (U+2014)
|
||||||
in user-facing copy or locale values. Use a period, colon, comma, semicolon, or
|
in user-facing copy or locale values. Use a period, colon, comma, semicolon, or
|
||||||
|
|||||||
+9
-9
@@ -234,19 +234,19 @@ stays an allowlist because a repo-wide error breaks every unrelated PR that adds
|
|||||||
a label — what made the first attempt unmergeable. **Append a path in the same PR
|
a label — what made the first attempt unmergeable. **Append a path in the same PR
|
||||||
that adds it or externalizes it** (`lib/sidebar` is one still off the list).
|
that adds it or externalizes it** (`lib/sidebar` is one still off the list).
|
||||||
Never delete an entry to make a build pass; `check-guard-allowlist.mjs` rejects
|
Never delete an entry to make a build pass; `check-guard-allowlist.mjs` rejects
|
||||||
that unless the file is gone. Use `pnpm run lint:i18n <path>` to preview the
|
that unless the file is gone. `pnpm run lint:i18n <path>` previews the guard on a
|
||||||
guard on a path not yet on the list.
|
path not yet listed.
|
||||||
|
|
||||||
`pnpm run i18n:ratchet` guards all new/changed lines independently of that
|
`pnpm run i18n:ratchet` guards all new/changed lines independently of that
|
||||||
allowlist; untouched literals are not reported.
|
allowlist; untouched literals are not reported.
|
||||||
|
|
||||||
The rule sees JSX literals but misses `confirm()` and plain `.ts` helper copy; it
|
The rule sees only JSX literals. What it cannot inspect — SCREAMING_CASE tables,
|
||||||
also skips SCREAMING_CASE constants, so review config tables by eye. `i18n:check`
|
plain `.ts` helpers, parameter defaults, toast/setter arguments — is gated by
|
||||||
gates key/catalog drift, `<Trans>` indices, inline plurals, module-scope `t()`, and
|
`scripts/check-nonjsx-copy.mjs` on the same allowlist, run by `i18n:check` and
|
||||||
the **pseudo-locale** (Settings → General → Appearance) completeness check. It
|
the ratchet; silence a legitimate one with `// i18n-exempt: <reason>` (required).
|
||||||
needs **Node 24**. Full guide:
|
`i18n:check` also gates key/catalog drift, `<Trans>` indices, inline plurals,
|
||||||
[`docs/i18n.md`](../../docs/i18n.md); spec:
|
module-scope `t()`, and the **pseudo-locale** check. Needs **Node 24**. Guide:
|
||||||
[`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md).
|
[`docs/i18n.md`](../../docs/i18n.md); spec [`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md).
|
||||||
|
|
||||||
## Markdown safety
|
## Markdown safety
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ type InvitePageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function useInvitePreview(token: string | undefined) {
|
function useInvitePreview(token: string | undefined) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [preview, setPreview] = useState<InvitePreview | null>(null);
|
const [preview, setPreview] = useState<InvitePreview | null>(null);
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setPreviewError("This invite link is missing its token.");
|
setPreviewError(t("auth:inviteLinkMissingToken"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -35,8 +36,8 @@ function useInvitePreview(token: string | undefined) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPreviewError(
|
setPreviewError(
|
||||||
err instanceof ApiError
|
err instanceof ApiError
|
||||||
? "This invite link is invalid or has expired. Ask an admin for a new one."
|
? t("auth:inviteLinkInvalidOrExpired")
|
||||||
: "Could not load this invite. Please try again.",
|
: t("auth:couldNotLoadInvite"),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -163,8 +164,8 @@ export function InvitePage({ token }: InvitePageProps) {
|
|||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setError(
|
setError(
|
||||||
err instanceof ApiError
|
err instanceof ApiError
|
||||||
? (err.message ?? "Could not accept this invite.")
|
? (err.message ?? t("auth:couldNotAcceptInvite"))
|
||||||
: "Something went wrong. Please try again.",
|
: t("auth:somethingWentWrong"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,14 +61,10 @@ export function LoginPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(
|
setError(err.status === 429 ? t("auth:tooManyAttempts") : t("auth:invalidEmailOrPassword"));
|
||||||
err.status === 429
|
|
||||||
? "Too many attempts. Please wait a moment and try again."
|
|
||||||
: "Invalid email or password.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError("Something went wrong. Please try again.");
|
setError(t("auth:somethingWentWrong"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,12 +116,12 @@ export function SetupWizard() {
|
|||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(
|
setError(
|
||||||
err.status === 409
|
err.status === 409
|
||||||
? "Setup is no longer available - an account already exists."
|
? t("auth:setupNoLongerAvailable")
|
||||||
: (err.message ?? "Setup failed. Please try again."),
|
: (err.message ?? t("auth:setupFailed")),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError("Something went wrong. Please try again.");
|
setError(t("auth:somethingWentWrong"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0;
|
const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0;
|
||||||
|
// i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale.
|
||||||
const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query");
|
const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query");
|
||||||
const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true);
|
const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true);
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) {
|
|||||||
// Use committedQuery (not the unflushed draft) so the saved preset always
|
// Use committedQuery (not the unflushed draft) so the saved preset always
|
||||||
// matches what is currently displayed in the list.
|
// matches what is currently displayed in the list.
|
||||||
const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0;
|
const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0;
|
||||||
|
// i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale.
|
||||||
const suggestedLabel =
|
const suggestedLabel =
|
||||||
committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query");
|
committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query");
|
||||||
const onOpenSaveDialog = () => {
|
const onOpenSaveDialog = () => {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function tintFor(name: string): string {
|
|||||||
* elements (tabs, dots, badges) when a full avatar is overkill.
|
* elements (tabs, dots, badges) when a full avatar is overkill.
|
||||||
*/
|
*/
|
||||||
export function agentTint(name: string | null | undefined): string {
|
export function agentTint(name: string | null | undefined): string {
|
||||||
|
// i18n-exempt: hash seed for the tint, never rendered.
|
||||||
return tintFor((name ?? "").trim() || "Agent");
|
return tintFor((name ?? "").trim() || "Agent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
* translating either would change stored data and the instructions the model
|
* translating either would change stored data and the instructions the model
|
||||||
* receives, which is a bug rather than a missing translation.
|
* receives, which is a bug rather than a missing translation.
|
||||||
*/
|
*/
|
||||||
|
// i18n-exempt: persisted as the task's own title. See the file header.
|
||||||
export const DEFAULT_ONBOARDING_TASK_TITLE = "Setup Workspace";
|
export const DEFAULT_ONBOARDING_TASK_TITLE = "Setup Workspace";
|
||||||
|
|
||||||
|
// i18n-exempt: sent verbatim to the coordinator agent as its prompt. See the file header.
|
||||||
export const DEFAULT_ONBOARDING_TASK_DESCRIPTION = `Investigate the project in https://github.com/org/repo. Replace this URL with each repository that should be managed in this workspace.
|
export const DEFAULT_ONBOARDING_TASK_DESCRIPTION = `Investigate the project in https://github.com/org/repo. Replace this URL with each repository that should be managed in this workspace.
|
||||||
|
|
||||||
As the CEO agent:
|
As the CEO agent:
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export function providerLabel(id: string): string {
|
|||||||
return PROVIDER_LABELS[id] ?? id;
|
return PROVIDER_LABELS[id] ?? id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// i18n-exempt: agent product names.
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
"claude-acp": "Claude (ACP)",
|
"claude-acp": "Claude (ACP)",
|
||||||
"codex-acp": "Codex (ACP)",
|
"codex-acp": "Codex (ACP)",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function GitSection() {
|
|||||||
() =>
|
() =>
|
||||||
runOp(
|
runOp(
|
||||||
async () => {
|
async () => {
|
||||||
|
// i18n-exempt: becomes the git commit message. See the comment below.
|
||||||
await officeApi.gitPush(activeWorkspaceId, {
|
await officeApi.gitPush(activeWorkspaceId, {
|
||||||
// Literal: this becomes the git COMMIT MESSAGE, so it is persisted
|
// Literal: this becomes the git COMMIT MESSAGE, so it is persisted
|
||||||
// content rather than UI copy. The input placeholder that shows it
|
// content rather than UI copy. The input placeholder that shows it
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type ProfileMcpConfigCardProps = {
|
|||||||
onToastError: (error: unknown) => void;
|
onToastError: (error: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// i18n-exempt: MCP server config the user copies verbatim, including the example header value.
|
||||||
const POPULAR_SERVERS: Record<string, Record<string, unknown>> = {
|
const POPULAR_SERVERS: Record<string, Record<string, unknown>> = {
|
||||||
playwright: {
|
playwright: {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
@@ -76,11 +77,14 @@ const POPULAR_SERVERS: Record<string, Record<string, unknown>> = {
|
|||||||
// Tool names and MCP-server product names are identifiers, not copy: they are
|
// Tool names and MCP-server product names are identifiers, not copy: they are
|
||||||
// interpolated as values so the pseudo-locale cannot turn them into something
|
// interpolated as values so the pseudo-locale cannot turn them into something
|
||||||
// the user cannot type or look up.
|
// the user cannot type or look up.
|
||||||
|
// i18n-exempt: server name and tool ids are config values the user copies verbatim into an agent.
|
||||||
const KANDEV_MCP_NAME = "Kandev MCP";
|
const KANDEV_MCP_NAME = "Kandev MCP";
|
||||||
|
|
||||||
|
// i18n-exempt: server name and tool ids are config values the user copies verbatim into an agent.
|
||||||
const KANDEV_TOOL_NAMES =
|
const KANDEV_TOOL_NAMES =
|
||||||
"list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task";
|
"list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task";
|
||||||
|
|
||||||
|
// i18n-exempt: MCP server product names.
|
||||||
const POPULAR_SERVER_NAMES: Record<string, string> = {
|
const POPULAR_SERVER_NAMES: Record<string, string> = {
|
||||||
playwright: "Playwright MCP",
|
playwright: "Playwright MCP",
|
||||||
"chrome-devtools": "Chrome DevTools MCP",
|
"chrome-devtools": "Chrome DevTools MCP",
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ function ExecutorCreatePageContent() {
|
|||||||
// own. Translating it would make an executor's stored name depend on the
|
// own. Translating it would make an executor's stored name depend on the
|
||||||
// locale it happened to be created in. The Select labels beside it are copy
|
// locale it happened to be created in. The Select labels beside it are copy
|
||||||
// and do go through `t()`.
|
// and do go through `t()`.
|
||||||
|
// i18n-exempt: seeded executor name is persisted user data. See the comment above.
|
||||||
const [name, setName] = useState(() =>
|
const [name, setName] = useState(() =>
|
||||||
initialType === "remote_docker" ? "Remote Docker" : "Local Docker",
|
initialType === "remote_docker" ? "Remote Docker" : "Local Docker",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { createWorkflowDuplication } from "./workflow-duplication";
|
|||||||
// These names are PERSISTED as the workflow's step names, so they deliberately
|
// These names are PERSISTED as the workflow's step names, so they deliberately
|
||||||
// stay English — translating them would write localized values into the
|
// stay English — translating them would write localized values into the
|
||||||
// database.
|
// database.
|
||||||
|
// i18n-exempt: persisted workflow step names. See the comment above.
|
||||||
export const DEFAULT_CUSTOM_STEPS: StepDefinition[] = [
|
export const DEFAULT_CUSTOM_STEPS: StepDefinition[] = [
|
||||||
{ name: "Todo", position: 0, color: "bg-slate-500" },
|
{ name: "Todo", position: 0, color: "bg-slate-500" },
|
||||||
{ name: "In Progress", position: 1, color: "bg-blue-500" },
|
{ name: "In Progress", position: 1, color: "bg-blue-500" },
|
||||||
@@ -118,6 +119,7 @@ export function useWorkflowCreation({
|
|||||||
? workflowTemplates.find((item) => item.id === selectedTemplateId)
|
? workflowTemplates.find((item) => item.id === selectedTemplateId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const tempId = newClientId("temp-workflow");
|
const tempId = newClientId("temp-workflow");
|
||||||
|
// i18n-exempt: persisted workflow name. See the comment below.
|
||||||
const workflow: Workflow = {
|
const workflow: Workflow = {
|
||||||
id: toWorkflowId(tempId),
|
id: toWorkflowId(tempId),
|
||||||
workspace_id: toWorkspaceId(workspace.id),
|
workspace_id: toWorkspaceId(workspace.id),
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ function buildDraftRepo(
|
|||||||
manualRepoPath: string,
|
manualRepoPath: string,
|
||||||
): RepositoryItem {
|
): RepositoryItem {
|
||||||
const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim();
|
const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim();
|
||||||
|
// i18n-exempt: persisted repository name. See the comment below.
|
||||||
// "New Repository" is PERSISTED as the repository's name, not rendered copy —
|
// "New Repository" is PERSISTED as the repository's name, not rendered copy —
|
||||||
// a stored name must not depend on the locale it was created in.
|
// a stored name must not depend on the locale it was created in.
|
||||||
|
// i18n-exempt: persisted repository name. See the comment above.
|
||||||
const name =
|
const name =
|
||||||
selectedRepo?.name ?? path.split("/").filter(Boolean).slice(-1)[0] ?? "New Repository";
|
selectedRepo?.name ?? path.split("/").filter(Boolean).slice(-1)[0] ?? "New Repository";
|
||||||
return {
|
return {
|
||||||
@@ -108,6 +110,7 @@ async function saveNewRepository(
|
|||||||
) {
|
) {
|
||||||
const created = await createRepositoryAction({
|
const created = await createRepositoryAction({
|
||||||
workspace_id: workspace?.id ?? repo.workspace_id,
|
workspace_id: workspace?.id ?? repo.workspace_id,
|
||||||
|
// i18n-exempt: persisted repository name, same contract as buildDraftRepo above.
|
||||||
name: repo.name.trim() || "New Repository",
|
name: repo.name.trim() || "New Repository",
|
||||||
source_type: repo.source_type || "local",
|
source_type: repo.source_type || "local",
|
||||||
local_path: repo.local_path,
|
local_path: repo.local_path,
|
||||||
@@ -129,6 +132,7 @@ async function saveNewRepository(
|
|||||||
// Like the repository name above, the seeded script name and command are
|
// Like the repository name above, the seeded script name and command are
|
||||||
// PERSISTED and sent to a shell verbatim, so both stay in English.
|
// PERSISTED and sent to a shell verbatim, so both stay in English.
|
||||||
const scripts = await Promise.all(
|
const scripts = await Promise.all(
|
||||||
|
// i18n-exempt: persisted script name, sent to a shell verbatim. See the comment above.
|
||||||
repo.scripts.map((script, index) =>
|
repo.scripts.map((script, index) =>
|
||||||
createRepositoryScriptAction({
|
createRepositoryScriptAction({
|
||||||
repository_id: created.id,
|
repository_id: created.id,
|
||||||
@@ -195,6 +199,7 @@ async function saveExistingRepository({
|
|||||||
if (script.id.startsWith("temp-script-"))
|
if (script.id.startsWith("temp-script-"))
|
||||||
return createRepositoryScriptAction({
|
return createRepositoryScriptAction({
|
||||||
repository_id: repoId,
|
repository_id: repoId,
|
||||||
|
// i18n-exempt: persisted script name, sent to a shell verbatim.
|
||||||
name: script.name.trim() || "New Script",
|
name: script.name.trim() || "New Script",
|
||||||
command: script.command.trim() || 'echo ""',
|
command: script.command.trim() || 'echo ""',
|
||||||
position: script.position ?? index,
|
position: script.position ?? index,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
buildDefaultPermissions,
|
buildDefaultPermissions,
|
||||||
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
|
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
|
||||||
} from "@/lib/agent-permissions";
|
} from "@/lib/agent-permissions";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export type CliProfileEditorMode = "create" | "edit";
|
export type CliProfileEditorMode = "create" | "edit";
|
||||||
|
|
||||||
@@ -235,8 +236,8 @@ export function CliProfileEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buttonLabel(mode: CliProfileEditorMode, saving: boolean): string {
|
function buttonLabel(mode: CliProfileEditorMode, saving: boolean): string {
|
||||||
if (saving) return mode === "edit" ? "Saving..." : "Creating...";
|
if (saving) return mode === "edit" ? t("agents:savingProfile") : t("agents:creatingProfile");
|
||||||
return mode === "edit" ? "Save profile" : "Create profile";
|
return mode === "edit" ? t("agents:saveProfile") : t("agents:createProfile");
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditorFooter({
|
function EditorFooter({
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type AutomationEditorProps = {
|
|||||||
// verbatim, and it is compared with `===` in useAutoPromptUpdate to decide
|
// verbatim, and it is compared with `===` in useAutoPromptUpdate to decide
|
||||||
// whether the user has edited it. Both make it protocol, not copy — the same
|
// whether the user has edited it. Both make it protocol, not copy — the same
|
||||||
// call the Jira (#2177), Linear (#2179) and Sentry (#2182) migrations made.
|
// call the Jira (#2177), Linear (#2179) and Sentry (#2182) migrations made.
|
||||||
|
// i18n-exempt: persisted prompt, sent to the agent and compared with ===. See the comment above.
|
||||||
const DEFAULT_PROMPT = "Run scheduled automation.\n\nTrigger: {{trigger.type}}";
|
const DEFAULT_PROMPT = "Run scheduled automation.\n\nTrigger: {{trigger.type}}";
|
||||||
|
|
||||||
const defaultForm: FormState = {
|
const defaultForm: FormState = {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export function buildCreatePayload(
|
|||||||
repositoryIds: string[],
|
repositoryIds: string[],
|
||||||
pending: PendingTrigger[],
|
pending: PendingTrigger[],
|
||||||
): CreateAutomationRequest {
|
): CreateAutomationRequest {
|
||||||
|
// i18n-exempt: persisted automation name. See the comment below.
|
||||||
return {
|
return {
|
||||||
workspace_id: workspaceId,
|
workspace_id: workspaceId,
|
||||||
// Persisted as the automation's name — user data, so it stays English
|
// Persisted as the automation's name — user data, so it stays English
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function pickSelectionFromOptionId(
|
|||||||
if (optionId.startsWith(DISCOVERED_PREFIX)) {
|
if (optionId.startsWith(DISCOVERED_PREFIX)) {
|
||||||
const path = optionId.slice(DISCOVERED_PREFIX.length);
|
const path = optionId.slice(DISCOVERED_PREFIX.length);
|
||||||
const match = discoveredRepos.find((r) => r.path === path);
|
const match = discoveredRepos.find((r) => r.path === path);
|
||||||
|
// i18n-exempt: persisted repository name. See the comment below.
|
||||||
return {
|
return {
|
||||||
kind: "discovered",
|
kind: "discovered",
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
* schedule composer edits, and computes the next firing so the editor can show
|
* schedule composer edits, and computes the next firing so the editor can show
|
||||||
* the resolved instant instead of leaving it implicit.
|
* the resolved instant instead of leaving it implicit.
|
||||||
*/
|
*/
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export type Frequency =
|
export type Frequency =
|
||||||
| "every-5m"
|
| "every-5m"
|
||||||
@@ -420,21 +421,24 @@ export function timeZoneAbbreviation(timeZone: string, at: Date = new Date()): s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAY_NAMES = [
|
// Catalog KEYS, not copy: this array is built once at module load, so resolving
|
||||||
"Sunday",
|
// it here would freeze the weekday names at the boot locale (docs/i18n.md,
|
||||||
"Monday",
|
// "no module-scope t()"). `describeSchedule` resolves them per call instead.
|
||||||
"Tuesday",
|
const WEEKDAY_KEYS = [
|
||||||
"Wednesday",
|
"common:weekdaySunday",
|
||||||
"Thursday",
|
"common:weekdayMonday",
|
||||||
"Friday",
|
"common:weekdayTuesday",
|
||||||
"Saturday",
|
"common:weekdayWednesday",
|
||||||
|
"common:weekdayThursday",
|
||||||
|
"common:weekdayFriday",
|
||||||
|
"common:weekdaySaturday",
|
||||||
];
|
];
|
||||||
|
|
||||||
const INTERVAL_LABELS: Partial<Record<Frequency, string>> = {
|
const INTERVAL_LABEL_KEYS: Partial<Record<Frequency, string>> = {
|
||||||
"every-5m": "Every 5 minutes",
|
"every-5m": "automations:scheduleIntervalEvery5Minutes",
|
||||||
"every-15m": "Every 15 minutes",
|
"every-15m": "automations:scheduleIntervalEvery15Minutes",
|
||||||
"every-30m": "Every 30 minutes",
|
"every-30m": "automations:scheduleIntervalEvery30Minutes",
|
||||||
"every-6h": "Every 6 hours",
|
"every-6h": "automations:scheduleIntervalEvery6Hours",
|
||||||
};
|
};
|
||||||
|
|
||||||
const pad = (value: number) => String(value).padStart(2, "0");
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
@@ -445,11 +449,11 @@ const pad = (value: number) => String(value).padStart(2, "0");
|
|||||||
*/
|
*/
|
||||||
export function describeSchedule(expression: string, timeZone: string): string {
|
export function describeSchedule(expression: string, timeZone: string): string {
|
||||||
const trimmed = expression.trim();
|
const trimmed = expression.trim();
|
||||||
if (!trimmed) return "No schedule";
|
if (!trimmed) return t("automations:scheduleNone");
|
||||||
|
|
||||||
const spec = parseExpression(trimmed);
|
const spec = parseExpression(trimmed);
|
||||||
const interval = INTERVAL_LABELS[spec.frequency];
|
const intervalKey = INTERVAL_LABEL_KEYS[spec.frequency];
|
||||||
if (interval) return interval;
|
if (intervalKey) return t(intervalKey);
|
||||||
|
|
||||||
const zone = timeZone || "UTC";
|
const zone = timeZone || "UTC";
|
||||||
const zoneLabel = timeZoneAbbreviation(zone);
|
const zoneLabel = timeZoneAbbreviation(zone);
|
||||||
@@ -457,11 +461,15 @@ export function describeSchedule(expression: string, timeZone: string): string {
|
|||||||
|
|
||||||
switch (spec.frequency) {
|
switch (spec.frequency) {
|
||||||
case "hourly":
|
case "hourly":
|
||||||
return `Every hour at :${pad(spec.minute)}`;
|
return t("automations:scheduleEveryHourAt", { minute: pad(spec.minute) });
|
||||||
case "daily":
|
case "daily":
|
||||||
return `Every day at ${at} ${zoneLabel}`;
|
return t("automations:scheduleEveryDayAt", { time: at, zone: zoneLabel });
|
||||||
case "weekly":
|
case "weekly":
|
||||||
return `Every ${WEEKDAY_NAMES[spec.weekday]} at ${at} ${zoneLabel}`;
|
return t("automations:scheduleEveryWeekdayAt", {
|
||||||
|
weekday: t(WEEKDAY_KEYS[spec.weekday]),
|
||||||
|
time: at,
|
||||||
|
zone: zoneLabel,
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
return `${trimmed} · ${zoneLabel}`;
|
return `${trimmed} · ${zoneLabel}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,25 +29,28 @@ type ScheduleSelectorProps = {
|
|||||||
onChange: (config: Record<string, unknown>) => void;
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FREQUENCY_OPTIONS: { value: Frequency; label: string }[] = [
|
// Catalog keys rather than copy: both tables are built at module load, so a
|
||||||
{ value: "every-5m", label: "every 5 minutes" },
|
// `t()` here would freeze at the boot locale (docs/i18n.md). Each render
|
||||||
{ value: "every-15m", label: "every 15 minutes" },
|
// resolves them.
|
||||||
{ value: "every-30m", label: "every 30 minutes" },
|
const FREQUENCY_OPTIONS: { value: Frequency; labelKey: string }[] = [
|
||||||
{ value: "hourly", label: "every hour" },
|
{ value: "every-5m", labelKey: "automations:frequencyEvery5Minutes" },
|
||||||
{ value: "every-6h", label: "every 6 hours" },
|
{ value: "every-15m", labelKey: "automations:frequencyEvery15Minutes" },
|
||||||
{ value: "daily", label: "every day" },
|
{ value: "every-30m", labelKey: "automations:frequencyEvery30Minutes" },
|
||||||
{ value: "weekly", label: "every week" },
|
{ value: "hourly", labelKey: "automations:frequencyEveryHour" },
|
||||||
{ value: "custom", label: "a custom schedule" },
|
{ value: "every-6h", labelKey: "automations:frequencyEvery6Hours" },
|
||||||
|
{ value: "daily", labelKey: "automations:frequencyEveryDay" },
|
||||||
|
{ value: "weekly", labelKey: "automations:frequencyEveryWeek" },
|
||||||
|
{ value: "custom", labelKey: "automations:frequencyCustom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEEKDAYS = [
|
const WEEKDAY_KEYS = [
|
||||||
"Sunday",
|
"common:weekdaySunday",
|
||||||
"Monday",
|
"common:weekdayMonday",
|
||||||
"Tuesday",
|
"common:weekdayTuesday",
|
||||||
"Wednesday",
|
"common:weekdayWednesday",
|
||||||
"Thursday",
|
"common:weekdayThursday",
|
||||||
"Friday",
|
"common:weekdayFriday",
|
||||||
"Saturday",
|
"common:weekdaySaturday",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DESCRIPTORS = new Set([
|
const DESCRIPTORS = new Set([
|
||||||
@@ -211,7 +214,7 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
|
|||||||
const handleCustomBlur = () => {
|
const handleCustomBlur = () => {
|
||||||
const trimmed = customDraft.trim();
|
const trimmed = customDraft.trim();
|
||||||
if (!isValidExpression(trimmed)) {
|
if (!isValidExpression(trimmed)) {
|
||||||
setCustomError("That's not a schedule we can read. Try a 5-field cron like 0 9 * * 1-5.");
|
setCustomError(t("automations:scheduleUnparseable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCustomError(null);
|
setCustomError(null);
|
||||||
@@ -269,6 +272,7 @@ function FrequencySelect({
|
|||||||
value: Frequency;
|
value: Frequency;
|
||||||
onChange: (value: Frequency) => void;
|
onChange: (value: Frequency) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={(next) => onChange(next as Frequency)}>
|
<Select value={value} onValueChange={(next) => onChange(next as Frequency)}>
|
||||||
<SelectTrigger className="w-[170px] h-8" data-testid="schedule-frequency">
|
<SelectTrigger className="w-[170px] h-8" data-testid="schedule-frequency">
|
||||||
@@ -277,7 +281,7 @@ function FrequencySelect({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FREQUENCY_OPTIONS.map((option) => (
|
{FREQUENCY_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{t(option.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -357,16 +361,16 @@ function FrequencyDetail({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{WEEKDAYS.map((day, index) => (
|
{WEEKDAY_KEYS.map((dayKey, index) => (
|
||||||
<SelectItem key={day} value={String(index)}>
|
<SelectItem key={dayKey} value={String(index)}>
|
||||||
{day}
|
{t(dayKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground">at</span>
|
<span className="text-muted-foreground">{t("automations:scheduleAtSeparator")}</span>
|
||||||
<Input
|
<Input
|
||||||
type="time"
|
type="time"
|
||||||
value={`${pad(spec.hour)}:${pad(spec.minute)}`}
|
value={`${pad(spec.hour)}:${pad(spec.minute)}`}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const PRESETS = [
|
|||||||
|
|
||||||
// Shown inside the help text as an example; interpolated so the pseudo-locale
|
// Shown inside the help text as an example; interpolated so the pseudo-locale
|
||||||
// cannot accent it into syntax that no longer parses.
|
// cannot accent it into syntax that no longer parses.
|
||||||
|
// i18n-exempt: cron shortcut syntax shown as an example. See the comment above.
|
||||||
const CRON_SHORTCUT_EXAMPLES = "@hourly, @daily, @weekly, @every 5m";
|
const CRON_SHORTCUT_EXAMPLES = "@hourly, @daily, @weekly, @every 5m";
|
||||||
|
|
||||||
export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
|
export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const AZURE_DEVOPS_ROUTE = "/azure-devops";
|
|||||||
// `newPreset` in components/github/default-queries-section.tsx. The seeded WIQL
|
// `newPreset` in components/github/default-queries-section.tsx. The seeded WIQL
|
||||||
// is Azure DevOps' query language, not copy.
|
// is Azure DevOps' query language, not copy.
|
||||||
function newQuery(kind: QueryKind): AzureDevOpsQueryPreset {
|
function newQuery(kind: QueryKind): AzureDevOpsQueryPreset {
|
||||||
|
// i18n-exempt: persisted, editable preset label. See the comment above.
|
||||||
return {
|
return {
|
||||||
id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
label: "New query",
|
label: "New query",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const PROMPT_TITLE_TOKEN = "{{title}}";
|
|||||||
// the row below, so it must stay locale-neutral — the same contract as
|
// the row below, so it must stay locale-neutral — the same contract as
|
||||||
// `newPreset` in components/github/action-presets-section.tsx.
|
// `newPreset` in components/github/action-presets-section.tsx.
|
||||||
function newAction(): AzureDevOpsActionPreset {
|
function newAction(): AzureDevOpsActionPreset {
|
||||||
|
// i18n-exempt: persisted, editable preset label. See the comment above.
|
||||||
return {
|
return {
|
||||||
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
label: "New action",
|
label: "New action",
|
||||||
|
|||||||
@@ -275,7 +275,9 @@ type ProjectsState = ReturnType<typeof useAzureDevOpsProjects>;
|
|||||||
// Azure DevOps names these two PAT scope groups in its own token UI. They are
|
// Azure DevOps names these two PAT scope groups in its own token UI. They are
|
||||||
// pointers into that screen, not copy, so they are interpolated as values — the
|
// pointers into that screen, not copy, so they are interpolated as values — the
|
||||||
// pseudo-locale must not transliterate a label the user has to find verbatim.
|
// pseudo-locale must not transliterate a label the user has to find verbatim.
|
||||||
|
// i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI.
|
||||||
const PAT_SCOPE_WORK_ITEMS = "Work Items";
|
const PAT_SCOPE_WORK_ITEMS = "Work Items";
|
||||||
|
// i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI.
|
||||||
const PAT_SCOPE_CODE = "Code";
|
const PAT_SCOPE_CODE = "Code";
|
||||||
|
|
||||||
function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) {
|
function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function launchText(payload: AzureDevOpsLaunchPayload) {
|
|||||||
"",
|
"",
|
||||||
plainText(pullRequest.description),
|
plainText(pullRequest.description),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
// i18n-exempt: persisted as the created task's title.
|
||||||
return {
|
return {
|
||||||
title: truncateRemoteTaskTitle(
|
title: truncateRemoteTaskTitle(
|
||||||
payload.action
|
payload.action
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type { AzureDevOpsActionPreset, AzureDevOpsQueryPreset } from "@/lib/type
|
|||||||
|
|
||||||
type Translate = (key: string) => string;
|
type Translate = (key: string) => string;
|
||||||
|
|
||||||
|
// i18n-exempt: WIQL is Azure DevOps' query language, not prose. See the file header.
|
||||||
const WIQL_START = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project";
|
const WIQL_START = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project";
|
||||||
const WIQL_ORDER = " ORDER BY [System.ChangedDate] DESC";
|
const WIQL_ORDER = " ORDER BY [System.ChangedDate] DESC";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ function wiql(condition?: string): string {
|
|||||||
return `${WIQL_START}${condition ? ` AND ${condition}` : ""}${WIQL_ORDER}`;
|
return `${WIQL_START}${condition ? ` AND ${condition}` : ""}${WIQL_ORDER}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
|
||||||
export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [
|
export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [
|
||||||
{
|
{
|
||||||
id: "recent",
|
id: "recent",
|
||||||
@@ -63,6 +65,7 @@ export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
|
||||||
export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [
|
export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [
|
||||||
{
|
{
|
||||||
id: "review-requested",
|
id: "review-requested",
|
||||||
@@ -90,6 +93,7 @@ export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
|
||||||
export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [
|
export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [
|
||||||
{
|
{
|
||||||
id: "implement",
|
id: "implement",
|
||||||
@@ -117,6 +121,7 @@ export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
|
||||||
export const DEFAULT_AZURE_PULL_REQUEST_ACTIONS: AzureDevOpsActionPreset[] = [
|
export const DEFAULT_AZURE_PULL_REQUEST_ACTIONS: AzureDevOpsActionPreset[] = [
|
||||||
{
|
{
|
||||||
id: "review",
|
id: "review",
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ function registerStartedSession({
|
|||||||
// locale-dependent value that then renders unchanged after a locale switch,
|
// locale-dependent value that then renders unchanged after a locale switch,
|
||||||
// on surfaces this directory does not own. Same call as the built-in layout
|
// on surfaces this directory does not own. Same call as the built-in layout
|
||||||
// profile names and the seeded workflow step names.
|
// profile names and the seeded workflow step names.
|
||||||
|
// i18n-exempt: persisted as the task title. See the comment above.
|
||||||
const derivedName = prompt.slice(0, 40) || "Config Chat";
|
const derivedName = prompt.slice(0, 40) || "Config Chat";
|
||||||
store.renameQuickChatSession(response.session_id, derivedName);
|
store.renameQuickChatSession(response.session_id, derivedName);
|
||||||
void persistQuickChatRename(response.session_id, response.task_id, derivedName).catch(
|
void persistQuickChatRename(response.session_id, response.task_id, derivedName).catch(
|
||||||
|
|||||||
@@ -32,16 +32,17 @@ import { createDirectory } from "@/lib/api/domains/fs-api";
|
|||||||
import type { Repository } from "@/lib/types/http";
|
import type { Repository } from "@/lib/types/http";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
// The returned strings are never rendered: the only caller uses this as a
|
// The returned strings are never rendered: the only caller uses this as a
|
||||||
// boolean gate (`!nameError`), so they stay English rather than becoming dead
|
// boolean gate (`!nameError`), so they stay English rather than becoming dead
|
||||||
// catalog entries.
|
// catalog entries.
|
||||||
export function validateLocalRepositoryName(name: string): string | null {
|
export function validateLocalRepositoryName(name: string): string | null {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return "Enter a repository name.";
|
if (!trimmed) return t("workspaces:enterRepositoryName");
|
||||||
if (trimmed === "." || trimmed === "..") return "Choose a different repository name.";
|
if (trimmed === "." || trimmed === "..") return t("workspaces:chooseDifferentRepositoryName");
|
||||||
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
|
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
|
||||||
return "The repository name must be one folder name.";
|
return t("workspaces:repositoryNameMustBeOneFolder");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|||||||
import { processFile, type FileDiffMetadata } from "@pierre/diffs";
|
import { processFile, type FileDiffMetadata } from "@pierre/diffs";
|
||||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||||
import { requestFileContent, requestFileContentAtRef } from "@/lib/ws/workspace-files";
|
import { requestFileContent, requestFileContentAtRef } from "@/lib/ws/workspace-files";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
type UseExpandableDiffOptions = {
|
type UseExpandableDiffOptions = {
|
||||||
sessionId: string | undefined;
|
sessionId: string | undefined;
|
||||||
@@ -222,7 +223,7 @@ export function useExpandableDiff({
|
|||||||
);
|
);
|
||||||
if (version === requestVersionRef.current) setLoadedContent(content);
|
if (version === requestVersionRef.current) setLoadedContent(content);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "Failed to load file content";
|
const msg = err instanceof Error ? err.message : t("task:failedToLoadFileContent");
|
||||||
console.error("[useExpandableDiff]", msg);
|
console.error("[useExpandableDiff]", msg);
|
||||||
if (version === requestVersionRef.current) setError(msg);
|
if (version === requestVersionRef.current) setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useSessionGitStatusByRepo,
|
useSessionGitStatusByRepo,
|
||||||
} from "@/hooks/domains/session/use-session-git-status";
|
} from "@/hooks/domains/session/use-session-git-status";
|
||||||
import type { ExternalVcsProvider } from "@/lib/utils/external-vcs-file-url";
|
import type { ExternalVcsProvider } from "@/lib/utils/external-vcs-file-url";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export type ExternalVcsFileLinkProps = UseExternalVcsFileLinkInput & {
|
export type ExternalVcsFileLinkProps = UseExternalVcsFileLinkInput & {
|
||||||
size?: "xs" | "sm" | "touch";
|
size?: "xs" | "sm" | "touch";
|
||||||
@@ -66,7 +67,7 @@ export function useExternalVcsFileStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function externalLinkLabel(provider: ExternalVcsProvider): string {
|
function externalLinkLabel(provider: ExternalVcsProvider): string {
|
||||||
return `Open file in ${providerNames[provider]}`;
|
return t("task:openFileInProvider", { provider: providerNames[provider] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExternalVcsFileLink({ size = "xs", ...input }: ExternalVcsFileLinkProps) {
|
export function ExternalVcsFileLink({ size = "xs", ...input }: ExternalVcsFileLinkProps) {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function initMermaid(theme: "dark" | "light" = "dark") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** SVG icon markup for the code toggle button (Tabler IconCode). */
|
/** SVG icon markup for the code toggle button (Tabler IconCode). */
|
||||||
|
// i18n-exempt: inline SVG markup.
|
||||||
const CODE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`;
|
const CODE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`;
|
||||||
|
|
||||||
/** Mermaid keywords regex for syntax highlighting. */
|
/** Mermaid keywords regex for syntax highlighting. */
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function actionPromptPlaceholders(t: TFunction): ScriptPlaceholder[] {
|
|||||||
// preset seeded in one locale and saved unedited would keep that locale's text
|
// preset seeded in one locale and saved unedited would keep that locale's text
|
||||||
// forever. Same contract as DEFAULT_PR_PRESETS in my-github/action-presets.ts.
|
// forever. Same contract as DEFAULT_PR_PRESETS in my-github/action-presets.ts.
|
||||||
function newPreset(): GitHubActionPreset {
|
function newPreset(): GitHubActionPreset {
|
||||||
|
// i18n-exempt: persisted, editable preset label. See the comment above.
|
||||||
return {
|
return {
|
||||||
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
label: "New action",
|
label: "New action",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
// and is editable in the row below, so it must stay locale-neutral — see
|
// and is editable in the row below, so it must stay locale-neutral — see
|
||||||
// `newPreset` in action-presets-section.tsx for the same contract.
|
// `newPreset` in action-presets-section.tsx for the same contract.
|
||||||
function newPreset(): StoredQueryPreset {
|
function newPreset(): StoredQueryPreset {
|
||||||
|
// i18n-exempt: persisted, editable preset label. See the comment above.
|
||||||
return {
|
return {
|
||||||
value: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
value: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
label: "New query",
|
label: "New query",
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ type FormState = {
|
|||||||
cleanupPolicy: CleanupPolicy;
|
cleanupPolicy: CleanupPolicy;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// i18n-exempt: GitHub search syntax and label ids, shown as examples.
|
||||||
const DEFAULT_QUERY = "type:issue state:open";
|
const DEFAULT_QUERY = "type:issue state:open";
|
||||||
|
// i18n-exempt: GitHub label ids, shown as examples.
|
||||||
const ISSUE_LABEL_EXAMPLES = "bug, enhancement, priority:high";
|
const ISSUE_LABEL_EXAMPLES = "bug, enhancement, priority:high";
|
||||||
|
|
||||||
function makeDefaultForm(workspaceId: string): FormState {
|
function makeDefaultForm(workspaceId: string): FormState {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function issueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
|
|||||||
* Seeded into the prompt field, then persisted and sent to the agent verbatim.
|
* Seeded into the prompt field, then persisted and sent to the agent verbatim.
|
||||||
* Deliberately NOT translated — see `DEFAULT_REVIEW_WATCH_PROMPT`.
|
* Deliberately NOT translated — see `DEFAULT_REVIEW_WATCH_PROMPT`.
|
||||||
*/
|
*/
|
||||||
|
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
|
||||||
export const DEFAULT_ISSUE_WATCH_PROMPT = `You have been assigned a GitHub issue to work on.
|
export const DEFAULT_ISSUE_WATCH_PROMPT = `You have been assigned a GitHub issue to work on.
|
||||||
|
|
||||||
**Issue:** {{issue.link}}
|
**Issue:** {{issue.link}}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export function toTaskPreset(stored: GitHubActionPreset): TaskPreset {
|
|||||||
// into a user's saved presets and leave them there after a locale switch.
|
// into a user's saved presets and leave them there after a locale switch.
|
||||||
// `prompt_template` is additionally sent to the agent verbatim. Localizing these
|
// `prompt_template` is additionally sent to the agent verbatim. Localizing these
|
||||||
// needs a key/persisted-value split, the same open item as PR_PRESETS.
|
// needs a key/persisted-value split, the same open item as PR_PRESETS.
|
||||||
|
// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type.
|
||||||
export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
|
export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
|
||||||
{
|
{
|
||||||
id: "review",
|
id: "review",
|
||||||
@@ -95,6 +96,7 @@ export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type.
|
||||||
export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
|
export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
|
||||||
{
|
{
|
||||||
id: "implement",
|
id: "implement",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type PresetOption = {
|
|||||||
// them there after a locale switch. Localizing these needs a key/persisted-value
|
// them there after a locale switch. Localizing these needs a key/persisted-value
|
||||||
// split in this module, the same open item as the built-in layout profile names.
|
// split in this module, the same open item as the built-in layout profile names.
|
||||||
// `value`, `filter` and `group` are wire values and a GitHub search query.
|
// `value`, `filter` and `group` are wire values and a GitHub search query.
|
||||||
|
// i18n-exempt: persisted saved-search seed, and the query beside it is GitHub search syntax.
|
||||||
export const PR_PRESETS: PresetOption[] = [
|
export const PR_PRESETS: PresetOption[] = [
|
||||||
{
|
{
|
||||||
value: "review_requested",
|
value: "review_requested",
|
||||||
@@ -65,6 +66,7 @@ export const PR_PRESETS: PresetOption[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// i18n-exempt: persisted saved-search seed, and the query beside it is GitHub search syntax.
|
||||||
export const ISSUE_PRESETS: PresetOption[] = [
|
export const ISSUE_PRESETS: PresetOption[] = [
|
||||||
{
|
{
|
||||||
value: "assigned",
|
value: "assigned",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { PRComment } from "@/lib/types/github";
|
|||||||
import { CollapsibleSection, AddToContextButton, FeedbackItemRow } from "./pr-shared";
|
import { CollapsibleSection, AddToContextButton, FeedbackItemRow } from "./pr-shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// i18n-exempt: builds the message body sent to the agent, not UI copy.
|
||||||
function buildCommentMessage(comment: PRComment, prUrl: string): string {
|
function buildCommentMessage(comment: PRComment, prUrl: string): string {
|
||||||
const location = comment.path
|
const location = comment.path
|
||||||
? `\`${comment.path}${comment.line > 0 ? `:${comment.line}` : ""}\``
|
? `\`${comment.path}${comment.line > 0 ? `:${comment.line}` : ""}\``
|
||||||
|
|||||||
@@ -7,22 +7,23 @@ import { Button } from "@kandev/ui/button";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||||
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
|
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function formatTimeAgo(dateStr: string): string {
|
export function formatTimeAgo(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
if (isNaN(date.getTime())) return "";
|
if (isNaN(date.getTime())) return "";
|
||||||
const diff = Date.now() - date.getTime();
|
const diff = Date.now() - date.getTime();
|
||||||
if (diff < 0) return "just now";
|
if (diff < 0) return t("github:timeAgoJustNow");
|
||||||
const minutes = Math.floor(diff / 60000);
|
const minutes = Math.floor(diff / 60000);
|
||||||
if (minutes < 1) return "just now";
|
if (minutes < 1) return t("github:timeAgoJustNow");
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return t("github:timeAgoMinutes", { count: minutes });
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return t("github:timeAgoHours", { count: hours });
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
if (days < 7) return `${days}d ago`;
|
if (days < 7) return t("github:timeAgoDays", { count: days });
|
||||||
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
if (days < 30) return t("github:timeAgoWeeks", { count: Math.floor(days / 7) });
|
||||||
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
if (days < 365) return t("github:timeAgoMonths", { count: Math.floor(days / 30) });
|
||||||
return `${Math.floor(days / 365)}y ago`;
|
return t("github:timeAgoYears", { count: Math.floor(days / 365) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMs(ms: number): string {
|
function formatMs(ms: number): string {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function reviewWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
|
|||||||
* Deliberately NOT translated: it is agent instructions and stored data, so
|
* Deliberately NOT translated: it is agent instructions and stored data, so
|
||||||
* translating it would write locale-dependent text into a user's saved watch.
|
* translating it would write locale-dependent text into a user's saved watch.
|
||||||
*/
|
*/
|
||||||
|
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
|
||||||
export const DEFAULT_REVIEW_WATCH_PROMPT = `Review Pull Request #{{pr.number}}: {{pr.title}}
|
export const DEFAULT_REVIEW_WATCH_PROMPT = `Review Pull Request #{{pr.number}}: {{pr.title}}
|
||||||
Repository: {{pr.repo}}
|
Repository: {{pr.repo}}
|
||||||
PR: {{pr.link}}
|
PR: {{pr.link}}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { useMemo } from "react";
|
|||||||
// than written into the catalog: they contain a literal `{{task_prompt}}` token,
|
// than written into the catalog: they contain a literal `{{task_prompt}}` token,
|
||||||
// which i18next would read as an interpolation and substitute away, and the
|
// which i18next would read as an interpolation and substitute away, and the
|
||||||
// pseudo-locale would transliterate the URL.
|
// pseudo-locale would transliterate the URL.
|
||||||
|
// i18n-exempt: example prompt text, shown as a sample of what is sent verbatim to the agent.
|
||||||
const STEP_PROMPT_EXAMPLE = "Analyze the task: {{task_prompt}}";
|
const STEP_PROMPT_EXAMPLE = "Analyze the task: {{task_prompt}}";
|
||||||
|
// i18n-exempt: example prompt text, shown as a sample of what is sent verbatim to the agent.
|
||||||
const FINAL_PROMPT_EXAMPLE = "Analyze the task: Pull Request ready for review: https://...";
|
const FINAL_PROMPT_EXAMPLE = "Analyze the task: Pull Request ready for review: https://...";
|
||||||
|
|
||||||
// Pulled out of review-watch-dialog.tsx to keep that file under the 600-line
|
// Pulled out of review-watch-dialog.tsx to keep that file under the 600-line
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
// seeded in one locale and saved unedited would keep that locale's text forever.
|
// seeded in one locale and saved unedited would keep that locale's text forever.
|
||||||
// Same contract as `newPreset` in components/github/action-presets-section.tsx.
|
// Same contract as `newPreset` in components/github/action-presets-section.tsx.
|
||||||
function newPreset(): GitLabActionPreset {
|
function newPreset(): GitLabActionPreset {
|
||||||
|
// i18n-exempt: persisted, editable preset label. See the comment above.
|
||||||
return {
|
return {
|
||||||
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
label: "New action",
|
label: "New action",
|
||||||
|
|||||||
@@ -482,7 +482,10 @@ export function MRDetailPanelComponent({
|
|||||||
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||||
const sessionId = useAppStore((state) => state.tasks.activeSessionId);
|
const sessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||||
const { status, loading } = useGitLabStatus();
|
const { status, loading } = useGitLabStatus();
|
||||||
useEffect(() => setPanelTitle(panelId, mr ? `MR !${mr.mr_iid}` : "Merge Request"), [mr, panelId]);
|
useEffect(
|
||||||
|
() => setPanelTitle(panelId, mr ? `MR !${mr.mr_iid}` : t("task:panelMergeRequest")),
|
||||||
|
[mr, panelId, t],
|
||||||
|
);
|
||||||
if (!mr || !workspaceId || !sessionId)
|
if (!mr || !workspaceId || !sessionId)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function useProjectMemberSearch({
|
|||||||
host,
|
host,
|
||||||
project,
|
project,
|
||||||
}: Pick<Props, "workspaceId" | "host" | "project">) {
|
}: Pick<Props, "workspaceId" | "host" | "project">) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [members, setMembers] = useState<GitLabProjectMember[]>([]);
|
const [members, setMembers] = useState<GitLabProjectMember[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
@@ -101,7 +102,9 @@ function useProjectMemberSearch({
|
|||||||
if (isActive(generation, requestIdentity)) setMembers(result);
|
if (isActive(generation, requestIdentity)) setMembers(result);
|
||||||
} catch (searchError) {
|
} catch (searchError) {
|
||||||
if (isActive(generation, requestIdentity)) {
|
if (isActive(generation, requestIdentity)) {
|
||||||
setError(searchError instanceof Error ? searchError.message : "Member search failed");
|
setError(
|
||||||
|
searchError instanceof Error ? searchError.message : t("gitlab:memberSearchFailed"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isActive(generation, requestIdentity)) setSearching(false);
|
if (isActive(generation, requestIdentity)) setSearching(false);
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ export type GitLabWatchForm = {
|
|||||||
// locale would keep that locale's prompt forever, and the agent reads it as an
|
// locale would keep that locale's prompt forever, and the agent reads it as an
|
||||||
// instruction rather than as UI copy. Same contract as
|
// instruction rather than as UI copy. Same contract as
|
||||||
// DEFAULT_ISSUE_WATCH_PROMPT in components/github/issue-watch-placeholders.ts.
|
// DEFAULT_ISSUE_WATCH_PROMPT in components/github/issue-watch-placeholders.ts.
|
||||||
|
// i18n-exempt: default prompt, persisted and sent verbatim to the agent.
|
||||||
const DEFAULT_REVIEW_PROMPT =
|
const DEFAULT_REVIEW_PROMPT =
|
||||||
"Review GitLab merge request {{mr.url}}. Summarize risks and leave actionable feedback.";
|
"Review GitLab merge request {{mr.url}}. Summarize risks and leave actionable feedback.";
|
||||||
|
// i18n-exempt: default prompt, persisted and sent verbatim to the agent.
|
||||||
const DEFAULT_ISSUE_PROMPT =
|
const DEFAULT_ISSUE_PROMPT =
|
||||||
"Investigate GitLab issue {{issue.url}} and implement the requested change.";
|
"Investigate GitLab issue {{issue.url}} and implement the requested change.";
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function checkSummary(checks: ChangeRequestDetailCheck[]) {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// i18n-exempt: builds the message body sent to the agent, not UI copy.
|
||||||
function buildCheckContext(check: ChangeRequestDetailCheck) {
|
function buildCheckContext(check: ChangeRequestDetailCheck) {
|
||||||
return [
|
return [
|
||||||
`CI check **${check.name}** failed (${checkConclusionLabel(check)}).`,
|
`CI check **${check.name}** failed (${checkConclusionLabel(check)}).`,
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ describe("IntegrationRepositoryFilter", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("All repositories"));
|
fireEvent.click(screen.getByText("All repositories"));
|
||||||
fireEvent.change(await screen.findByPlaceholderText("Filter repositories..."), {
|
// The unset props fall back to the `integrations:*` catalog, which spells the
|
||||||
|
// placeholder with a real ellipsis. Both app consumers pass all three props,
|
||||||
|
// so this default is only ever rendered here.
|
||||||
|
fireEvent.change(await screen.findByPlaceholderText("Filter repositories…"), {
|
||||||
target: { value: "web" },
|
target: { value: "web" },
|
||||||
});
|
});
|
||||||
fireEvent.click(await screen.findByRole("option", { name: "acme/web" }));
|
fireEvent.click(await screen.findByRole("option", { name: "acme/web" }));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Combobox, type ComboboxOption } from "@/components/combobox";
|
import { Combobox, type ComboboxOption } from "@/components/combobox";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const ALL_REPOSITORIES = "__all_repositories__";
|
const ALL_REPOSITORIES = "__all_repositories__";
|
||||||
|
|
||||||
@@ -30,19 +31,25 @@ export function IntegrationRepositoryFilter({
|
|||||||
onValueChange,
|
onValueChange,
|
||||||
options,
|
options,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
allLabel = "All repositories",
|
allLabel,
|
||||||
searchPlaceholder = "Filter repositories...",
|
searchPlaceholder,
|
||||||
emptyMessage = "No repositories found.",
|
emptyMessage,
|
||||||
testId,
|
testId,
|
||||||
dropdownTestId,
|
dropdownTestId,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
className,
|
className,
|
||||||
}: IntegrationRepositoryFilterProps) {
|
}: IntegrationRepositoryFilterProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Resolved here rather than as parameter defaults: a destructuring default is
|
||||||
|
// evaluated before the component body runs, so `t` is not yet in scope there.
|
||||||
|
const allRepositoriesLabel = allLabel ?? t("integrations:allRepositories");
|
||||||
|
const searchPlaceholderText = searchPlaceholder ?? t("integrations:filterRepositories");
|
||||||
|
const emptyMessageText = emptyMessage ?? t("integrations:noRepositoriesFound");
|
||||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
value: ALL_REPOSITORIES,
|
value: ALL_REPOSITORIES,
|
||||||
label: allLabel,
|
label: allRepositoriesLabel,
|
||||||
keywords: ["all", "repositories", "repos"],
|
keywords: ["all", "repositories", "repos"],
|
||||||
},
|
},
|
||||||
...options.map((option) => ({
|
...options.map((option) => ({
|
||||||
@@ -51,7 +58,7 @@ export function IntegrationRepositoryFilter({
|
|||||||
keywords: option.keywords ?? [option.label],
|
keywords: option.keywords ?? [option.label],
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
[allLabel, options],
|
[allRepositoriesLabel, options],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,9 +71,9 @@ export function IntegrationRepositoryFilter({
|
|||||||
}}
|
}}
|
||||||
options={comboboxOptions}
|
options={comboboxOptions}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
placeholder={allLabel}
|
placeholder={allRepositoriesLabel}
|
||||||
searchPlaceholder={searchPlaceholder}
|
searchPlaceholder={searchPlaceholderText}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessageText}
|
||||||
triggerClassName={triggerClassName}
|
triggerClassName={triggerClassName}
|
||||||
className={className}
|
className={className}
|
||||||
testId={testId}
|
testId={testId}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ function parseMaxInflightTasks(raw: string): number | null | "invalid" {
|
|||||||
// `status`, `created`), operators and a project key — and the string is
|
// `status`, `created`), operators and a project key — and the string is
|
||||||
// persisted on the watch and sent upstream verbatim. Translating any of it would
|
// persisted on the watch and sent upstream verbatim. Translating any of it would
|
||||||
// produce a query Jira rejects.
|
// produce a query Jira rejects.
|
||||||
|
// i18n-exempt: JQL is Jira's query language, not prose.
|
||||||
const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`;
|
const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`;
|
||||||
|
|
||||||
function makeEmptyForm(workspaceId: string): FormState {
|
function makeEmptyForm(workspaceId: string): FormState {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function jiraIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
|
|||||||
* instruction rather than as UI copy. Same contract as
|
* instruction rather than as UI copy. Same contract as
|
||||||
* `DEFAULT_ISSUE_WATCH_PROMPT` in components/github/issue-watch-placeholders.ts.
|
* `DEFAULT_ISSUE_WATCH_PROMPT` in components/github/issue-watch-placeholders.ts.
|
||||||
*/
|
*/
|
||||||
|
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
|
||||||
export const DEFAULT_JIRA_ISSUE_WATCH_PROMPT = `You have been assigned a JIRA ticket to work on.
|
export const DEFAULT_JIRA_ISSUE_WATCH_PROMPT = `You have been assigned a JIRA ticket to work on.
|
||||||
|
|
||||||
**Ticket:** {{issue.url}}
|
**Ticket:** {{issue.url}}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { JiraAuthMethod } from "@/lib/types/jira";
|
|||||||
const SESSION_COOKIE_NAME = "cloud.session.token";
|
const SESSION_COOKIE_NAME = "cloud.session.token";
|
||||||
const SSO_SESSION_COOKIE_NAME = "tenant.session.token";
|
const SSO_SESSION_COOKIE_NAME = "tenant.session.token";
|
||||||
const ATLASSIAN_COOKIE_HOST = "https://*.atlassian.net";
|
const ATLASSIAN_COOKIE_HOST = "https://*.atlassian.net";
|
||||||
|
// i18n-exempt: keyboard shortcut, interpolated so no locale rewrites it. See the comment above.
|
||||||
const DEVTOOLS_SHORTCUT = "Cmd+Opt+I / Ctrl+Shift+I";
|
const DEVTOOLS_SHORTCUT = "Cmd+Opt+I / Ctrl+Shift+I";
|
||||||
// Atlassian's API-token page. The visible link text is the URL itself, so it is
|
// Atlassian's API-token page. The visible link text is the URL itself, so it is
|
||||||
// an identifier in both roles and never reaches the catalog.
|
// an identifier in both roles and never reaches the catalog.
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function toTaskPreset(stored: JiraStoredPreset): JiraTaskPreset {
|
|||||||
// into user settings on the first save, so a locale-dependent value would be
|
// into user settings on the first save, so a locale-dependent value would be
|
||||||
// written into the user's data and stay there after a locale switch. The prompt
|
// written into the user's data and stay there after a locale switch. The prompt
|
||||||
// templates are also sent to the agent as instructions rather than shown as copy.
|
// templates are also sent to the agent as instructions rather than shown as copy.
|
||||||
|
// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type.
|
||||||
export const DEFAULT_JIRA_PRESETS: JiraStoredPreset[] = [
|
export const DEFAULT_JIRA_PRESETS: JiraStoredPreset[] = [
|
||||||
{
|
{
|
||||||
id: "implement",
|
id: "implement",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const MY_JIRA_ROUTE = "/jira";
|
|||||||
// seeded in one locale and saved unedited would keep that locale's text forever.
|
// seeded in one locale and saved unedited would keep that locale's text forever.
|
||||||
// Same contract as `newPreset` in components/github/action-presets-section.tsx.
|
// Same contract as `newPreset` in components/github/action-presets-section.tsx.
|
||||||
function newPreset(): JiraStoredPreset {
|
function newPreset(): JiraStoredPreset {
|
||||||
|
// i18n-exempt: persisted, editable preset label. See the comment above.
|
||||||
return {
|
return {
|
||||||
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
label: "New action",
|
label: "New action",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { StateProvider } from "@/components/state-provider";
|
|||||||
import type { Task } from "@/components/kanban-card";
|
import type { Task } from "@/components/kanban-card";
|
||||||
import type { WorkflowStep } from "@/components/kanban-column";
|
import type { WorkflowStep } from "@/components/kanban-column";
|
||||||
import type { ForegroundActivity, TaskPendingAction } from "@/lib/types/http";
|
import type { ForegroundActivity, TaskPendingAction } from "@/lib/types/http";
|
||||||
import { SwimlaneGraphContent } from "./swimlane-graph-content";
|
import { i18n, t } from "@/lib/i18n";
|
||||||
|
import { SwimlaneGraphContent, moveTaskAcrossSwimlaneSteps } from "./swimlane-graph-content";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -98,3 +99,65 @@ describe("SwimlaneGraphContent — waiting-for-input variants", () => {
|
|||||||
expect(container.querySelector(ICON_LOADER2)).toBeNull();
|
expect(container.querySelector(ICON_LOADER2)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("moveTaskAcrossSwimlaneSteps — move failure reporting", () => {
|
||||||
|
const WORKFLOW_ID = "wf-1";
|
||||||
|
const TASK_ID = "task-1";
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const snapshot = {
|
||||||
|
tasks: [{ id: TASK_ID, workflowStepId: "step-1", position: 0 }],
|
||||||
|
};
|
||||||
|
const snapshots: Record<string, typeof snapshot> = { [WORKFLOW_ID]: snapshot };
|
||||||
|
const state = {
|
||||||
|
kanbanMulti: { snapshots },
|
||||||
|
setWorkflowSnapshot: (id: string, next: typeof snapshot) => {
|
||||||
|
snapshots[id] = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { getState: () => state } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(rejection: unknown) {
|
||||||
|
const errors: { message: string }[] = [];
|
||||||
|
await moveTaskAcrossSwimlaneSteps({
|
||||||
|
task: { id: TASK_ID, primarySessionId: null } as never,
|
||||||
|
taskId: TASK_ID,
|
||||||
|
targetColumnId: "step-2",
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
store: harness(),
|
||||||
|
moveTaskById: (() => Promise.reject(rejection)) as never,
|
||||||
|
onMoveError: (error) => errors.push(error),
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports the localized fallback when the rejection is not an Error", async () => {
|
||||||
|
// Asserted under PSEUDO, not English. `toBe(t(key))` in English cannot tell a
|
||||||
|
// `t()` call from the identical hardcoded literal — a mutation putting
|
||||||
|
// "Failed to move task" back survived that version of this test. Under a
|
||||||
|
// locale that transliterates, only a real catalog lookup comes out accented.
|
||||||
|
await i18n.changeLanguage("pseudo");
|
||||||
|
try {
|
||||||
|
const [error] = await move("plain string rejection");
|
||||||
|
expect(error.message).toBe(t("task:failedToMoveTask"));
|
||||||
|
expect(error.message).toMatch(/[^\x20-\x7E]/);
|
||||||
|
} finally {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the fallback with the task identity attached", async () => {
|
||||||
|
expect(await move("plain string rejection")).toEqual([
|
||||||
|
{ message: t("task:failedToMoveTask"), taskId: TASK_ID, sessionId: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a real Error's message instead of replacing it with copy", async () => {
|
||||||
|
// A server diagnostic is not copy: swallowing it would leave the user with a
|
||||||
|
// generic sentence and no way to tell two failures apart.
|
||||||
|
expect(await move(new Error("step is at its WIP limit"))).toEqual([
|
||||||
|
{ message: "step is at its WIP limit", taskId: TASK_ID, sessionId: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
|||||||
import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
|
import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
|
||||||
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
|
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export type SwimlaneGraphContentProps = {
|
export type SwimlaneGraphContentProps = {
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
@@ -146,7 +147,15 @@ type SwimlaneGraphDndOptions = {
|
|||||||
onMoveError?: (error: MoveTaskError) => void;
|
onMoveError?: (error: MoveTaskError) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function moveTaskAcrossSwimlaneSteps({
|
/**
|
||||||
|
* Optimistically moves a task, rolling the snapshot back and reporting a message
|
||||||
|
* when the backend refuses.
|
||||||
|
*
|
||||||
|
* Exported for tests: the localized fallback on the non-`Error` branch is the
|
||||||
|
* kind of copy no rendering test reaches, since it is handed to `onMoveError`
|
||||||
|
* rather than to the DOM.
|
||||||
|
*/
|
||||||
|
export async function moveTaskAcrossSwimlaneSteps({
|
||||||
task,
|
task,
|
||||||
taskId,
|
taskId,
|
||||||
targetColumnId,
|
targetColumnId,
|
||||||
@@ -199,7 +208,7 @@ async function moveTaskAcrossSwimlaneSteps({
|
|||||||
.getState()
|
.getState()
|
||||||
.setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks });
|
.setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks });
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : "Failed to move task";
|
const message = error instanceof Error ? error.message : t("task:failedToMoveTask");
|
||||||
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
|
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
useKanbanExternalLinkAvailability,
|
useKanbanExternalLinkAvailability,
|
||||||
} from "@/components/kanban-external-link-availability";
|
} from "@/components/kanban-external-link-availability";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sentinel step ID used to collect tasks whose workflow_step_id no longer
|
* Sentinel step ID used to collect tasks whose workflow_step_id no longer
|
||||||
@@ -139,7 +140,7 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban
|
|||||||
.getState()
|
.getState()
|
||||||
.setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks });
|
.setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks });
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : "Failed to move task";
|
const message = error instanceof Error ? error.message : t("task:failedToMoveTask");
|
||||||
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
|
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function linearIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[]
|
|||||||
// would keep that locale's prompt forever, and the agent reads it as an
|
// would keep that locale's prompt forever, and the agent reads it as an
|
||||||
// instruction rather than as UI copy — it also has to keep matching the backend
|
// instruction rather than as UI copy — it also has to keep matching the backend
|
||||||
// .md, which has no locale at all.
|
// .md, which has no locale at all.
|
||||||
|
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
|
||||||
export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on.
|
export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on.
|
||||||
|
|
||||||
**Issue:** {{issue.url}}
|
**Issue:** {{issue.url}}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ async function startQuickChatForAgent(
|
|||||||
(session.kind ?? "chat") === "chat" &&
|
(session.kind ?? "chat") === "chat" &&
|
||||||
!isQuickChatSetupSessionId(session.sessionId),
|
!isQuickChatSetupSessionId(session.sessionId),
|
||||||
).length + 1;
|
).length + 1;
|
||||||
|
// i18n-exempt: persisted as the quick-chat task title, same contract as use-config-chat.ts.
|
||||||
const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`;
|
const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`;
|
||||||
const response = await startQuickChat(workspaceId, {
|
const response = await startQuickChat(workspaceId, {
|
||||||
agent_profile_id: agentId,
|
agent_profile_id: agentId,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function RunNotice({ code, message }: { code: string; message: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useRunReview(taskId: string | null | undefined, sessionId?: string | null) {
|
function useRunReview(taskId: string | null | undefined, sessionId?: string | null) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [notice, setNotice] = useState<{ code: string; message: string } | null>(null);
|
const [notice, setNotice] = useState<{ code: string; message: string } | null>(null);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ function useRunReview(taskId: string | null | undefined, sessionId?: string | nu
|
|||||||
await runTaskReview({ taskId, sessionId: sessionId ?? undefined });
|
await runTaskReview({ taskId, sessionId: sessionId ?? undefined });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as { code?: string })?.code ?? "";
|
const code = (error as { code?: string })?.code ?? "";
|
||||||
const message = error instanceof Error ? error.message : "Could not start the review";
|
const message = error instanceof Error ? error.message : t("review:couldNotStartReview");
|
||||||
setNotice({ code, message });
|
setNotice({ code, message });
|
||||||
} finally {
|
} finally {
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function sentryIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[]
|
|||||||
// would keep that locale's prompt forever, and the agent reads it as an
|
// would keep that locale's prompt forever, and the agent reads it as an
|
||||||
// instruction rather than as UI copy — it also has to keep matching the backend
|
// instruction rather than as UI copy — it also has to keep matching the backend
|
||||||
// .md, which has no locale at all.
|
// .md, which has no locale at all.
|
||||||
|
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
|
||||||
export const DEFAULT_SENTRY_ISSUE_WATCH_PROMPT = `You have been assigned a Sentry issue to triage and fix.
|
export const DEFAULT_SENTRY_ISSUE_WATCH_PROMPT = `You have been assigned a Sentry issue to triage and fix.
|
||||||
|
|
||||||
**Issue:** {{issue.url}}
|
**Issue:** {{issue.url}}
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; labelKey: string }> = [
|
|||||||
/** Placeholder tokens the launcher substitutes — identifiers, not copy. */
|
/** Placeholder tokens the launcher substitutes — identifiers, not copy. */
|
||||||
const PLACEHOLDER_HINT = "{cwd} {file} {rel} {line} {column}";
|
const PLACEHOLDER_HINT = "{cwd} {file} {rel} {line} {column}";
|
||||||
/** Example shell command shown in the field; a value the user types verbatim. */
|
/** Example shell command shown in the field; a value the user types verbatim. */
|
||||||
|
// i18n-exempt: shell command example the user copies verbatim.
|
||||||
const COMMAND_EXAMPLE = "code --goto {file}:{line}";
|
const COMMAND_EXAMPLE = "code --goto {file}:{line}";
|
||||||
/** Example of the URL this kind builds — a URL, not translatable copy. */
|
/** Example of the URL this kind builds — a URL, not translatable copy. */
|
||||||
const REMOTE_SSH_EXAMPLE = "vscode://vscode-remote/ssh-remote+user@host:/path/file:line";
|
const REMOTE_SSH_EXAMPLE = "vscode://vscode-remote/ssh-remote+user@host:/path/file:line";
|
||||||
/** Accepted URL schemes. The user types one of these verbatim into the field. */
|
/** Accepted URL schemes. The user types one of these verbatim into the field. */
|
||||||
|
// i18n-exempt: editor URL scheme identifiers, not prose.
|
||||||
const EDITOR_SCHEME_EXAMPLES = "vscode, cursor";
|
const EDITOR_SCHEME_EXAMPLES = "vscode, cursor";
|
||||||
|
|
||||||
export function getCustomKindLabel(t: TranslateKey, kind: string) {
|
export function getCustomKindLabel(t: TranslateKey, kind: string) {
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import { createExecutorProfile } from "@/lib/api/domains/settings-api";
|
|||||||
import type { ExecutorProfile } from "@/lib/types/http";
|
import type { ExecutorProfile } from "@/lib/types/http";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const PROFILE_NAME_EXAMPLE = "Production, Development";
|
|
||||||
|
|
||||||
type ExecutorProfileDialogProps = {
|
type ExecutorProfileDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -76,7 +74,7 @@ export function ExecutorProfileDialog({
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder={t("executors:eGProductionDevelopment", {
|
placeholder={t("executors:eGProductionDevelopment", {
|
||||||
example: PROFILE_NAME_EXAMPLE,
|
example: t("executors:profileNameExample"),
|
||||||
})}
|
})}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ function ToolsPreview() {
|
|||||||
* locale — the surrounding sentence is the only copy, and it interpolates them
|
* locale — the surrounding sentence is the only copy, and it interpolates them
|
||||||
* so the pseudo-locale cannot turn them into dead pointers.
|
* so the pseudo-locale cannot turn them into dead pointers.
|
||||||
*/
|
*/
|
||||||
|
// i18n-exempt: third-party product names.
|
||||||
const SNIPPET_CARDS: Array<{
|
const SNIPPET_CARDS: Array<{
|
||||||
title: string;
|
title: string;
|
||||||
build: (streamableUrl: string) => string;
|
build: (streamableUrl: string) => string;
|
||||||
@@ -189,6 +190,7 @@ const SNIPPET_CARDS: Array<{
|
|||||||
build: buildClaudeCodeConfig,
|
build: buildClaudeCodeConfig,
|
||||||
buildCli: buildClaudeCodeCliCommand,
|
buildCli: buildClaudeCodeCliCommand,
|
||||||
cliTarget: "~/.claude.json",
|
cliTarget: "~/.claude.json",
|
||||||
|
// i18n-exempt: third-party product names.
|
||||||
},
|
},
|
||||||
{ title: "Cursor", path: "~/.cursor/mcp.json", build: buildCursorConfig },
|
{ title: "Cursor", path: "~/.cursor/mcp.json", build: buildCursorConfig },
|
||||||
{
|
{
|
||||||
@@ -198,6 +200,7 @@ const SNIPPET_CARDS: Array<{
|
|||||||
buildCli: buildCodexCliCommand,
|
buildCli: buildCodexCliCommand,
|
||||||
cliTarget: "~/.codex/config.toml",
|
cliTarget: "~/.codex/config.toml",
|
||||||
},
|
},
|
||||||
|
// i18n-exempt: third-party product names.
|
||||||
{
|
{
|
||||||
title: "Auggie CLI",
|
title: "Auggie CLI",
|
||||||
path: "~/.augment/settings.json",
|
path: "~/.augment/settings.json",
|
||||||
@@ -213,6 +216,7 @@ const SNIPPET_CARDS: Array<{
|
|||||||
globalPath: "~/.config/opencode/opencode.json",
|
globalPath: "~/.config/opencode/opencode.json",
|
||||||
},
|
},
|
||||||
build: buildOpenCodeConfig,
|
build: buildOpenCodeConfig,
|
||||||
|
// i18n-exempt: third-party product names.
|
||||||
},
|
},
|
||||||
{ title: "GitHub Copilot CLI", path: "~/.copilot/mcp-config.json", build: buildCopilotCliConfig },
|
{ title: "GitHub Copilot CLI", path: "~/.copilot/mcp-config.json", build: buildCopilotCliConfig },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function lspAutoInstallConfigurable(
|
|||||||
return language.autoInstallSupported && preferenceLanguages.includes(language.id);
|
return language.autoInstallSupported && preferenceLanguages.includes(language.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// i18n-exempt: programming language names are not translated.
|
||||||
export const LSP_LANGUAGE_OPTIONS: LspLanguageOption[] = [
|
export const LSP_LANGUAGE_OPTIONS: LspLanguageOption[] = [
|
||||||
{
|
{
|
||||||
id: "typescript",
|
id: "typescript",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const DEFAULT_IMAGE_TAG = "kandev/multi-agent:latest";
|
|||||||
//
|
//
|
||||||
// The kandev backend mounts the agentctl binary into /usr/local/bin/agentctl
|
// The kandev backend mounts the agentctl binary into /usr/local/bin/agentctl
|
||||||
// at container creation time, so users do NOT need to bake it in here.
|
// at container creation time, so users do NOT need to bake it in here.
|
||||||
|
// i18n-exempt: Dockerfile contents, not prose.
|
||||||
const DEFAULT_DOCKERFILE = `FROM node:22-slim
|
const DEFAULT_DOCKERFILE = `FROM node:22-slim
|
||||||
|
|
||||||
RUN apt-get update \\
|
RUN apt-get update \\
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ export function SecretsSettings({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
initialItems,
|
initialItems,
|
||||||
}: SecretsSettingsProps) {
|
}: SecretsSettingsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const state = useSecretsState(scope, workspaceId, initialItems);
|
const state = useSecretsState(scope, workspaceId, initialItems);
|
||||||
const actions = useSecretsActions(state);
|
const actions = useSecretsActions(state);
|
||||||
const globalAdd = useAppStore((s) => s.addSecret);
|
const globalAdd = useAppStore((s) => s.addSecret);
|
||||||
@@ -509,7 +510,7 @@ export function SecretsSettings({
|
|||||||
? (workspaceNames.find((workspace) => workspace.id === secret.workspace_id)?.name ??
|
? (workspaceNames.find((workspace) => workspace.id === secret.workspace_id)?.name ??
|
||||||
secret.workspace_id ??
|
secret.workspace_id ??
|
||||||
"workspace")
|
"workspace")
|
||||||
: "Global";
|
: t("settings:globalScope");
|
||||||
|
|
||||||
// Route the transfer result by the RETURNED item's scope, never the page
|
// Route the transfer result by the RETURNED item's scope, never the page
|
||||||
// scope: Global targets always land in the Global store (from any page),
|
// scope: Global targets always land in the Global store (from any page),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { StorageSettingHelp } from "./storage-setting-help";
|
|||||||
import { SettingRow } from "./storage-policy-fields";
|
import { SettingRow } from "./storage-policy-fields";
|
||||||
|
|
||||||
// Keep this display snapshot in sync with backend DependencyDirectoryAllowlist.
|
// Keep this display snapshot in sync with backend DependencyDirectoryAllowlist.
|
||||||
|
// i18n-exempt: dependency directory name on disk.
|
||||||
const DEPENDENCY_DIRECTORIES = [
|
const DEPENDENCY_DIRECTORIES = [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"bower_components",
|
"bower_components",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ function openStepDeleteDialog({
|
|||||||
// `name` becomes the step's stored name and `color` its Tailwind class — so
|
// `name` becomes the step's stored name and `color` its Tailwind class — so
|
||||||
// neither is translated; translating the name would write a localized string
|
// neither is translated; translating the name would write a localized string
|
||||||
// into the database.
|
// into the database.
|
||||||
|
// i18n-exempt: persisted workflow step name, same contract as DEFAULT_CUSTOM_STEPS.
|
||||||
const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
|
const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
|
||||||
|
|
||||||
function createDraftStep(workflow: Workflow, position: number): WorkflowStep {
|
function createDraftStep(workflow: Workflow, position: number): WorkflowStep {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const STEP_COLORS = [
|
|||||||
// The `prompt` bodies are deliberately NOT translated: clicking a template
|
// The `prompt` bodies are deliberately NOT translated: clicking a template
|
||||||
// writes the text into `WorkflowStep.prompt`, which is persisted and sent to
|
// writes the text into `WorkflowStep.prompt`, which is persisted and sent to
|
||||||
// the agent verbatim. Only the button `labelKey` is copy.
|
// the agent verbatim. Only the button `labelKey` is copy.
|
||||||
|
// i18n-exempt: seeded step prompts, persisted and sent verbatim to the agent.
|
||||||
export const PROMPT_TEMPLATES = [
|
export const PROMPT_TEMPLATES = [
|
||||||
{
|
{
|
||||||
labelKey: "workflows:templatePlan",
|
labelKey: "workflows:templatePlan",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createWorkflowStepAction, updateWorkflowStepAction } from "@/app/action
|
|||||||
|
|
||||||
// See `workflow-card-actions.ts`: both fields are persisted verbatim, so the
|
// See `workflow-card-actions.ts`: both fields are persisted verbatim, so the
|
||||||
// seeded step name deliberately stays English.
|
// seeded step name deliberately stays English.
|
||||||
|
// i18n-exempt: persisted workflow step name, same contract as DEFAULT_CUSTOM_STEPS.
|
||||||
const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
|
const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
|
||||||
|
|
||||||
function fallbackErrorMessage(error: unknown): string {
|
function fallbackErrorMessage(error: unknown): string {
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ export function useGitHubUrlErrorEffect(fs: DialogFormState, open: boolean) {
|
|||||||
}
|
}
|
||||||
const parsed = parseGitHubAnyUrl(trimmed);
|
const parsed = parseGitHubAnyUrl(trimmed);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
setGitHubUrlError("Invalid GitHub URL - expected github.com/owner/repo or .../pull/123");
|
setGitHubUrlError(t("task:invalidGitHubUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGitHubUrlError(null);
|
setGitHubUrlError(null);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import { Badge } from "@kandev/ui/badge";
|
import { Badge } from "@kandev/ui/badge";
|
||||||
import type { ForegroundActivity } from "@/lib/types/http";
|
import type { ForegroundActivity } from "@/lib/types/http";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type TaskHeaderProps = {
|
export type TaskHeaderProps = {
|
||||||
identifier?: string | null;
|
identifier?: string | null;
|
||||||
@@ -51,12 +52,12 @@ function resolveBadgeLabel(
|
|||||||
hasPendingClarification: boolean,
|
hasPendingClarification: boolean,
|
||||||
hasPendingPermission: boolean,
|
hasPendingPermission: boolean,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (hasPendingPermission) return "Permission requested";
|
if (hasPendingPermission) return "task:sessionStatusPermissionRequested";
|
||||||
if (hasPendingClarification) return "Waiting for input";
|
if (hasPendingClarification) return "task:sessionStatusWaitingForInput";
|
||||||
if (foregroundActivity === "generating") return "Generating";
|
if (foregroundActivity === "generating") return "task:sessionStatusGenerating";
|
||||||
if (foregroundActivity === "background") return "Background running";
|
if (foregroundActivity === "background") return "task:sessionStatusBackgroundRunning";
|
||||||
if (state === "WAITING_FOR_INPUT") return "Waiting for input";
|
if (state === "WAITING_FOR_INPUT") return "task:sessionStatusWaitingForInput";
|
||||||
return state ?? null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskHeader({
|
export function TaskHeader({
|
||||||
@@ -69,12 +70,16 @@ export function TaskHeader({
|
|||||||
hasPendingClarification = false,
|
hasPendingClarification = false,
|
||||||
hasPendingPermission = false,
|
hasPendingPermission = false,
|
||||||
}: TaskHeaderProps) {
|
}: TaskHeaderProps) {
|
||||||
const badgeLabel = resolveBadgeLabel(
|
const { t } = useTranslation();
|
||||||
|
const badgeKey = resolveBadgeLabel(
|
||||||
state,
|
state,
|
||||||
foregroundActivity,
|
foregroundActivity,
|
||||||
hasPendingClarification,
|
hasPendingClarification,
|
||||||
hasPendingPermission,
|
hasPendingPermission,
|
||||||
);
|
);
|
||||||
|
// `state` is a workflow-step name the user chose, so it is data and is
|
||||||
|
// rendered verbatim; only the derived activity badges are copy.
|
||||||
|
const badgeLabel = badgeKey ? t(badgeKey) : (state ?? null);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{identifier && (
|
{identifier && (
|
||||||
|
|||||||
@@ -12,18 +12,24 @@ type GitCredentialSnapshot = {
|
|||||||
transport?: unknown;
|
transport?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const workspaceMethodLabels: Record<string, string> = {
|
// Catalog keys, resolved per call: this module is imported once, so a `t()`
|
||||||
pat: "workspace personal access token",
|
// here would freeze at the boot locale (docs/i18n.md).
|
||||||
gh_cli: "workspace GitHub CLI account",
|
const workspaceMethodKeys: Record<string, string> = {
|
||||||
github_app_installation: "workspace GitHub App",
|
pat: "task:credentialWorkspacePat",
|
||||||
|
gh_cli: "task:credentialWorkspaceGhCli",
|
||||||
|
github_app_installation: "task:credentialWorkspaceGithubApp",
|
||||||
};
|
};
|
||||||
|
|
||||||
const transportLabels: Record<string, string> = {
|
const transportKeys: Record<string, string> = {
|
||||||
managed_https: "Kandev-managed GitHub HTTPS and gh",
|
managed_https: "task:credentialTransportManagedHttps",
|
||||||
profile_token: "executor-profile token",
|
profile_token: "task:credentialTransportProfileToken",
|
||||||
executor_selected: "executor Git and gh credentials",
|
executor_selected: "task:credentialTransportExecutorSelected",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function label(keys: Record<string, string>, id: string, fallbackKey: string): string {
|
||||||
|
return t(keys[id] ?? fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
function readSnapshot(
|
function readSnapshot(
|
||||||
metadata: Record<string, unknown> | null | undefined,
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
): GitCredentialSnapshot | null {
|
): GitCredentialSnapshot | null {
|
||||||
@@ -45,21 +51,21 @@ export function getGitCredentialDisplay(
|
|||||||
switch (snapshot.source) {
|
switch (snapshot.source) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return {
|
return {
|
||||||
source: "Managed workspace identity",
|
source: t("task:credentialSourceWorkspace"),
|
||||||
detail: workspaceMethodLabels[workspaceMethod] ?? "workspace GitHub credentials",
|
detail: label(workspaceMethodKeys, workspaceMethod, "task:credentialWorkspaceFallback"),
|
||||||
transport: transportLabels[transport] ?? "Kandev-managed task credentials",
|
transport: label(transportKeys, transport, "task:credentialTransportManagedFallback"),
|
||||||
};
|
};
|
||||||
case "executor_profile":
|
case "executor_profile":
|
||||||
return {
|
return {
|
||||||
source: "Executor-profile token",
|
source: t("task:credentialSourceExecutorProfile"),
|
||||||
detail: actor || "An explicit GH_TOKEN or GITHUB_TOKEN",
|
detail: actor || t("task:credentialExplicitToken"),
|
||||||
transport: transportLabels[transport] ?? "executor-profile token",
|
transport: label(transportKeys, transport, "task:credentialTransportProfileToken"),
|
||||||
};
|
};
|
||||||
case "executor":
|
case "executor":
|
||||||
return {
|
return {
|
||||||
source: "Executor credentials",
|
source: t("task:credentialSourceExecutor"),
|
||||||
detail: actor || t("task:credentialsAvailableInExecutor"),
|
detail: actor || t("task:credentialsAvailableInExecutor"),
|
||||||
transport: transportLabels[transport] ?? "executor Git and gh credentials",
|
transport: label(transportKeys, transport, "task:credentialTransportExecutorSelected"),
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
} from "./changes-panel-helpers";
|
} from "./changes-panel-helpers";
|
||||||
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
|
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
|
||||||
import type { PRDiffFile, TaskPR } from "@/lib/types/github";
|
import type { PRDiffFile, TaskPR } from "@/lib/types/github";
|
||||||
|
import { gitOperationLabel } from "@/hooks/use-git-with-feedback";
|
||||||
import { getGitCredentialDisplay } from "./changes-git-credential-display";
|
import { getGitCredentialDisplay } from "./changes-git-credential-display";
|
||||||
import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation";
|
import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +57,7 @@ import { useRemoteContributionResolution } from "./use-remote-contribution-resol
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
function useChangesPanelStoreData() {
|
function useChangesPanelStoreData() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||||
const activeSessionId = useEnvironmentSessionId();
|
const activeSessionId = useEnvironmentSessionId();
|
||||||
const taskTitle = useAppStore((state) => {
|
const taskTitle = useAppStore((state) => {
|
||||||
@@ -68,9 +70,12 @@ function useChangesPanelStoreData() {
|
|||||||
const activeSessionMetadata = useAppStore((state) =>
|
const activeSessionMetadata = useAppStore((state) =>
|
||||||
activeSessionId ? state.taskSessions.items[activeSessionId]?.metadata : undefined,
|
activeSessionId ? state.taskSessions.items[activeSessionId]?.metadata : undefined,
|
||||||
);
|
);
|
||||||
|
// `t` is a dependency even though the helper resolves through the module-level
|
||||||
|
// translator: without it the memo returns the labels built under the previous
|
||||||
|
// locale and the panel never restates them.
|
||||||
const gitCredentialDisplay = useMemo(
|
const gitCredentialDisplay = useMemo(
|
||||||
() => getGitCredentialDisplay(activeSessionMetadata),
|
() => getGitCredentialDisplay(activeSessionMetadata),
|
||||||
[activeSessionMetadata],
|
[activeSessionMetadata, t],
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
@@ -155,18 +160,19 @@ function usePerRepoCallbacks(
|
|||||||
vcsDialogs: ReturnType<typeof useVcsDialogs>,
|
vcsDialogs: ReturnType<typeof useVcsDialogs>,
|
||||||
gitHandlers: ReturnType<typeof useChangesGitHandlers>,
|
gitHandlers: ReturnType<typeof useChangesGitHandlers>,
|
||||||
) {
|
) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
onRepoStageAll: (repo: string) => {
|
onRepoStageAll: (repo: string) => {
|
||||||
gitHandlers.handleGitOperation(
|
gitHandlers.handleGitOperation(
|
||||||
() => git.stage(undefined, repo),
|
() => git.stage(undefined, repo),
|
||||||
repo ? `Stage all (${repo})` : "Stage all",
|
gitOperationLabel(t, "task:stageAll", repo),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onRepoUnstageAll: (repo: string) => {
|
onRepoUnstageAll: (repo: string) => {
|
||||||
gitHandlers.handleGitOperation(
|
gitHandlers.handleGitOperation(
|
||||||
() => git.unstage(undefined, repo),
|
() => git.unstage(undefined, repo),
|
||||||
repo ? `Unstage all (${repo})` : "Unstage all",
|
gitOperationLabel(t, "task:unstageAll", repo),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo),
|
onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function buildPlanContextItem(params: BuildContextItemsParams): ContextItem | nu
|
|||||||
return {
|
return {
|
||||||
kind: "plan",
|
kind: "plan",
|
||||||
id: PLAN_CONTEXT_PATH,
|
id: PLAN_CONTEXT_PATH,
|
||||||
label: "Plan",
|
label: t("task:panelPlan"),
|
||||||
taskId: taskId ?? undefined,
|
taskId: taskId ?? undefined,
|
||||||
pinned: planFile?.pinned,
|
pinned: planFile?.pinned,
|
||||||
onRemove: sid ? () => removeContextFile(sid, PLAN_CONTEXT_PATH) : undefined,
|
onRemove: sid ? () => removeContextFile(sid, PLAN_CONTEXT_PATH) : undefined,
|
||||||
|
|||||||
@@ -86,12 +86,12 @@ export function resolveInputPlaceholder(
|
|||||||
hasClarification: boolean,
|
hasClarification: boolean,
|
||||||
needsRecovery: boolean,
|
needsRecovery: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (needsRecovery) return "Choose a recovery option above to continue...";
|
if (needsRecovery) return t("task:composerPlaceholderRecovery");
|
||||||
if (hasClarification) return "Queue instructions while the question is pending...";
|
if (hasClarification) return t("task:composerPlaceholderClarification");
|
||||||
if (isAgentBusy) return "Queue instructions to the agent...";
|
if (isAgentBusy) return t("task:composerPlaceholderBusy");
|
||||||
if (activeDocumentType === "file") return "Continue working on the file...";
|
if (activeDocumentType === "file") return t("task:composerPlaceholderFile");
|
||||||
if (planModeEnabled) return "Continue working on the plan...";
|
if (planModeEnabled) return t("task:composerPlaceholderPlan");
|
||||||
return "Continue working on the task...";
|
return t("task:composerPlaceholderTask");
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaceholderArgs = {
|
type PlaceholderArgs = {
|
||||||
@@ -107,7 +107,7 @@ type PlaceholderArgs = {
|
|||||||
/** Picks the composer placeholder: an explicit override wins, then the
|
/** Picks the composer placeholder: an explicit override wins, then the
|
||||||
* "switching agent" state, then {@link resolveInputPlaceholder}. */
|
* "switching agent" state, then {@link resolveInputPlaceholder}. */
|
||||||
function pickInputPlaceholder(a: PlaceholderArgs): string {
|
function pickInputPlaceholder(a: PlaceholderArgs): string {
|
||||||
if (a.isMoving) return "Switching agent...";
|
if (a.isMoving) return t("task:composerPlaceholderSwitchingAgent");
|
||||||
// Preserve the prior `??` semantics: an explicit "" override (caller wants
|
// Preserve the prior `??` semantics: an explicit "" override (caller wants
|
||||||
// no placeholder text) must NOT fall through to the resolver default.
|
// no placeholder text) must NOT fall through to the resolver default.
|
||||||
if (a.override !== undefined) return a.override;
|
if (a.override !== undefined) return a.override;
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ function FailedSessionBanner({
|
|||||||
taskId,
|
taskId,
|
||||||
sessionId,
|
sessionId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
message = "This agent has stopped.",
|
message,
|
||||||
detail,
|
detail,
|
||||||
resumeLabel = "Resume",
|
resumeLabel,
|
||||||
resumingLabel = "Resuming...",
|
resumingLabel,
|
||||||
}: {
|
}: {
|
||||||
showDialog: boolean;
|
showDialog: boolean;
|
||||||
onShowDialog: (open: boolean) => void;
|
onShowDialog: (open: boolean) => void;
|
||||||
@@ -158,6 +158,11 @@ function FailedSessionBanner({
|
|||||||
resumingLabel?: string;
|
resumingLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Resolved in the body, not as parameter defaults: those are evaluated before
|
||||||
|
// the component body, where `t` does not yet exist (docs/i18n.md).
|
||||||
|
const bannerMessage = message ?? t("task:agentHasStopped");
|
||||||
|
const resumeText = resumeLabel ?? t("task:resume");
|
||||||
|
const resumingText = resumingLabel ?? t("task:resuming");
|
||||||
const [isResuming, setIsResuming] = useState(false);
|
const [isResuming, setIsResuming] = useState(false);
|
||||||
const [isStartingFresh, setIsStartingFresh] = useState(false);
|
const [isStartingFresh, setIsStartingFresh] = useState(false);
|
||||||
|
|
||||||
@@ -196,7 +201,7 @@ function FailedSessionBanner({
|
|||||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||||
<IconAlertTriangle className="h-4 w-4 text-orange-500 shrink-0" />
|
<IconAlertTriangle className="h-4 w-4 text-orange-500 shrink-0" />
|
||||||
<span className="truncate">{message}</span>
|
<span className="truncate">{bannerMessage}</span>
|
||||||
{detail && <span className="shrink-0 text-xs text-muted-foreground">({detail})</span>}
|
{detail && <span className="shrink-0 text-xs text-muted-foreground">({detail})</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -213,7 +218,7 @@ function FailedSessionBanner({
|
|||||||
disabled={isResuming || !profileExists}
|
disabled={isResuming || !profileExists}
|
||||||
>
|
>
|
||||||
<IconPlayerPlay className="h-3.5 w-3.5" />
|
<IconPlayerPlay className="h-3.5 w-3.5" />
|
||||||
{isResuming ? resumingLabel : resumeLabel}
|
{isResuming ? resumingText : resumeText}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { formatShortcut } from "@/lib/keyboard/utils";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
type SubmitButtonProps = {
|
type SubmitButtonProps = {
|
||||||
isAgentBusy: boolean;
|
isAgentBusy: boolean;
|
||||||
@@ -51,8 +52,8 @@ function submitTooltipDescription(
|
|||||||
submitDisabledReason?: string,
|
submitDisabledReason?: string,
|
||||||
) {
|
) {
|
||||||
if (submitDisabledReason) return submitDisabledReason;
|
if (submitDisabledReason) return submitDisabledReason;
|
||||||
if (isAgentBusy) return "Queue message";
|
if (isAgentBusy) return t("task:submitTooltipQueueMessage");
|
||||||
if (planModeEnabled) return "Request plan changes";
|
if (planModeEnabled) return t("task:submitTooltipRequestPlanChanges");
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
|
|||||||
import { isDirectory, getFileName } from "@/lib/utils/file-path";
|
import { isDirectory, getFileName } from "@/lib/utils/file-path";
|
||||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
type ContextPopoverProps = {
|
type ContextPopoverProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,7 +26,12 @@ type ContextPopoverProps = {
|
|||||||
|
|
||||||
const FILE_SEARCH_DEBOUNCE = 300;
|
const FILE_SEARCH_DEBOUNCE = 300;
|
||||||
|
|
||||||
const PLAN_CONTEXT_FILE: ContextFile = { path: "plan:context", name: "Plan", pinned: true };
|
// `name` is display copy; it is resolved per call rather than at module load.
|
||||||
|
const planContextFile = (): ContextFile => ({
|
||||||
|
path: "plan:context",
|
||||||
|
name: t("task:panelPlan"),
|
||||||
|
pinned: true,
|
||||||
|
});
|
||||||
|
|
||||||
function FileResultsList({
|
function FileResultsList({
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -228,11 +234,11 @@ function ContextPopoverList({
|
|||||||
{!query && (
|
{!query && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
|
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
|
||||||
onClick={() => onToggleFile(PLAN_CONTEXT_FILE)}
|
onClick={() => onToggleFile(planContextFile())}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={planContextEnabled}
|
checked={planContextEnabled}
|
||||||
onCheckedChange={() => onToggleFile(PLAN_CONTEXT_FILE)}
|
onCheckedChange={() => onToggleFile(planContextFile())}
|
||||||
className="h-3.5 w-3.5"
|
className="h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<IconListCheck className="h-4 w-4 text-muted-foreground shrink-0" />
|
<IconListCheck className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const ENVIRONMENT_SETUP_FAILED_KEY = "task:environmentSetupFailed";
|
|||||||
// backend: `environment preparation failed:` (manager_launch.go), the launch
|
// backend: `environment preparation failed:` (manager_launch.go), the launch
|
||||||
// race ("already has an agent running" / "race resolved during register"),
|
// race ("already has an agent running" / "race resolved during register"),
|
||||||
// container launch failures, and branch/fresh-branch prep errors.
|
// container launch failures, and branch/fresh-branch prep errors.
|
||||||
|
// i18n-exempt: substrings matched against backend error text, not copy.
|
||||||
const SETUP_FAILURE_SIGNATURES = [
|
const SETUP_FAILURE_SIGNATURES = [
|
||||||
"environment preparation failed",
|
"environment preparation failed",
|
||||||
"failed to launch container",
|
"failed to launch container",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes
|
|||||||
// Get the answer summary for display
|
// Get the answer summary for display
|
||||||
const getAnswerSummary = () => {
|
const getAnswerSummary = () => {
|
||||||
const response = metadata.response;
|
const response = metadata.response;
|
||||||
if (!response) return "No selection";
|
if (!response) return t("task:clarificationNoSelection");
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes
|
|||||||
parts.push(`"${response.custom_text}"`);
|
parts.push(`"${response.custom_text}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join(", ") : "No selection";
|
return parts.length > 0 ? parts.join(", ") : t("task:clarificationNoSelection");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { IdChip, KandevBody, KandevRow, KeyValueRow, ListItemRow, SummaryDot } f
|
|||||||
import { pickArray, pickNumber, pickString } from "./parse";
|
import { pickArray, pickNumber, pickString } from "./parse";
|
||||||
import type { KandevRenderer } from "./types";
|
import type { KandevRenderer } from "./types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
const DEFAULT_TITLE = "Walkthrough";
|
// i18n-exempt: prefix matched against the agent's raw tool result, not copy.
|
||||||
const RESULT_JSON_PREFIX = "Walkthrough saved:";
|
const RESULT_JSON_PREFIX = "Walkthrough saved:";
|
||||||
|
|
||||||
type WalkthroughStepLike = Record<string, unknown>;
|
type WalkthroughStepLike = Record<string, unknown>;
|
||||||
@@ -39,7 +40,7 @@ function resultPayload(result: unknown): unknown {
|
|||||||
|
|
||||||
function titleFrom(args: unknown, result: unknown): string {
|
function titleFrom(args: unknown, result: unknown): string {
|
||||||
const payload = resultPayload(result);
|
const payload = resultPayload(result);
|
||||||
return pickString(payload, "title") ?? pickString(args, "title") ?? DEFAULT_TITLE;
|
return pickString(payload, "title") ?? pickString(args, "title") ?? t("common:walkthrough");
|
||||||
}
|
}
|
||||||
|
|
||||||
function stepsFrom(args: unknown, result: unknown): WalkthroughStepLike[] {
|
function stepsFrom(args: unknown, result: unknown): WalkthroughStepLike[] {
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ function useMessageCommentActions({
|
|||||||
rootRef: RefObject<HTMLDivElement | null>;
|
rootRef: RefObject<HTMLDivElement | null>;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const addComment = useCommentsStore((state) => state.addComment);
|
const addComment = useCommentsStore((state) => state.addComment);
|
||||||
const updateComment = useCommentsStore((state) => state.updateComment);
|
const updateComment = useCommentsStore((state) => state.updateComment);
|
||||||
const removeComment = useCommentsStore((state) => state.removeComment);
|
const removeComment = useCommentsStore((state) => state.removeComment);
|
||||||
@@ -449,7 +450,7 @@ function useMessageCommentActions({
|
|||||||
const renderedText = rootRef.current?.textContent ?? "";
|
const renderedText = rootRef.current?.textContent ?? "";
|
||||||
const comment = commentFromTarget(message, sessionId, target, renderedText, feedback);
|
const comment = commentFromTarget(message, sessionId, target, renderedText, feedback);
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
setSaveError("The agent response changed. Select the text again.");
|
setSaveError(t("task:agentResponseChangedSelectAgain"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ function formatStatusLabel(
|
|||||||
ended: boolean,
|
ended: boolean,
|
||||||
endReason: string | undefined,
|
endReason: string | undefined,
|
||||||
) {
|
) {
|
||||||
if (!ended) return "watching";
|
if (!ended) return t("task:monitorStatusWatching");
|
||||||
if (status === "cancelled" || endReason === "session_restart") return "ended (session restart)";
|
if (status === "cancelled" || endReason === "session_restart")
|
||||||
return "ended";
|
return t("task:monitorStatusEndedSessionRestart");
|
||||||
|
return t("task:monitorStatusEnded");
|
||||||
}
|
}
|
||||||
|
|
||||||
type MonitorViewModel = {
|
type MonitorViewModel = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { isTerminalToolCallStatus, normalizeToolCallStatus } from "@/lib/utils/tool-call-status";
|
import { isTerminalToolCallStatus, normalizeToolCallStatus } from "@/lib/utils/tool-call-status";
|
||||||
import type { ShellExecOutputSummary } from "../types";
|
import type { ShellExecOutputSummary } from "../types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
type ShellOutputDisclosureProps = {
|
type ShellOutputDisclosureProps = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -20,9 +21,9 @@ type ShellOutputDisclosureProps = {
|
|||||||
|
|
||||||
function formatOutputSize(summary: ShellExecOutputSummary | undefined) {
|
function formatOutputSize(summary: ShellExecOutputSummary | undefined) {
|
||||||
const bytes = (summary?.stdout_bytes ?? 0) + (summary?.stderr_bytes ?? 0);
|
const bytes = (summary?.stdout_bytes ?? 0) + (summary?.stderr_bytes ?? 0);
|
||||||
if (bytes === 0) return "Output";
|
if (bytes === 0) return t("task:output");
|
||||||
if (bytes < 1024) return `Output · ${bytes} B`;
|
if (bytes < 1024) return `${t("task:output")} · ${bytes} B`;
|
||||||
return `Output · ${(bytes / 1024).toFixed(bytes < 10_240 ? 1 : 0)} KB`;
|
return `${t("task:output")} · ${(bytes / 1024).toFixed(bytes < 10_240 ? 1 : 0)} KB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OutputTranscript({ output }: { output: ShellCommandOutput }) {
|
function OutputTranscript({ output }: { output: ShellCommandOutput }) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
// `value` is the highlight.js language id and the language names are proper
|
// `value` is the highlight.js language id and the language names are proper
|
||||||
// nouns, so both stay untranslated. Only the "no language" option is copy, and
|
// nouns, so both stay untranslated. Only the "no language" option is copy, and
|
||||||
// it travels as a catalog key because this table is built at module load.
|
// it travels as a catalog key because this table is built at module load.
|
||||||
|
// i18n-exempt: programming language names are not translated.
|
||||||
const CODE_LANGUAGES: Array<{ value: string; label?: string; labelKey?: string }> = [
|
const CODE_LANGUAGES: Array<{ value: string; label?: string; labelKey?: string }> = [
|
||||||
{ value: "", labelKey: "task:codeLanguagePlain" },
|
{ value: "", labelKey: "task:codeLanguagePlain" },
|
||||||
{ value: "javascript", label: "JavaScript" },
|
{ value: "javascript", label: "JavaScript" },
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function computeDerivedState(params: {
|
|||||||
// transition for local quick-chat sessions doesn't deserve its own
|
// transition for local quick-chat sessions doesn't deserve its own
|
||||||
// tooltip — the editor is disabled, that's the signal.
|
// tooltip — the editor is disabled, that's the signal.
|
||||||
const submitDisabledReason =
|
const submitDisabledReason =
|
||||||
isDisabled && params.isPreparingEnvironment ? "The agent is still being set up." : undefined;
|
isDisabled && params.isPreparingEnvironment ? t("task:agentStillBeingSetUp") : undefined;
|
||||||
const hasPendingComments = !!(
|
const hasPendingComments = !!(
|
||||||
params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0
|
params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type { ImagePasteIssue } from "./clipboard-attachments";
|
|||||||
import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api";
|
import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api";
|
||||||
import { ApiError } from "@/lib/api/client";
|
import { ApiError } from "@/lib/api/client";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
type UseChatInputStateProps = {
|
type UseChatInputStateProps = {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -278,7 +279,7 @@ function useAttachments(sessionId: string | null, workspaceId?: string | null) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateAttachment(attachment.id, {
|
updateAttachment(attachment.id, {
|
||||||
uploadStatus: "failed",
|
uploadStatus: "failed",
|
||||||
uploadError: error instanceof ApiError ? error.message : "Upload failed",
|
uploadError: error instanceof ApiError ? error.message : t("task:attachmentUploadFailed"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { CHAT_PANEL_FALLBACK_LABEL, resolveChatPanelTitle } from "./dockview-panel-content";
|
import { t } from "@/lib/i18n";
|
||||||
|
import { resolveChatPanelTitle } from "./dockview-panel-content";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression: the generic "chat" placeholder dockview panel used to fall back
|
* Regression: the generic "chat" placeholder dockview panel used to fall back
|
||||||
@@ -8,27 +9,34 @@ import { CHAT_PANEL_FALLBACK_LABEL, resolveChatPanelTitle } from "./dockview-pan
|
|||||||
* gate inside `useChatSessionTitle` that suppressed the agent label for the
|
* gate inside `useChatSessionTitle` that suppressed the agent label for the
|
||||||
* non-session-scoped placeholder. The pure resolver imported here is the place
|
* non-session-scoped placeholder. The pure resolver imported here is the place
|
||||||
* the gate would have to be re-introduced, so this test pins the behavior.
|
* the gate would have to be re-introduced, so this test pins the behavior.
|
||||||
|
*
|
||||||
|
* The fallback is asserted through the catalog rather than as the literal
|
||||||
|
* "Agent": what this test is about is WHICH value is chosen, not what that
|
||||||
|
* value's English happens to be, and pinning the copy here would make an
|
||||||
|
* ordinary wording change fail four unrelated assertions.
|
||||||
*/
|
*/
|
||||||
|
const FALLBACK = () => t("task:panelAgent");
|
||||||
|
|
||||||
describe("resolveChatPanelTitle", () => {
|
describe("resolveChatPanelTitle", () => {
|
||||||
it("returns the agent label when one is provided", () => {
|
it("returns the agent label when one is provided", () => {
|
||||||
expect(resolveChatPanelTitle("Opus")).toBe("Opus");
|
expect(resolveChatPanelTitle("Opus", t)).toBe("Opus");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the generic 'Agent' label when null", () => {
|
it("falls back to the generic 'Agent' label when null", () => {
|
||||||
expect(resolveChatPanelTitle(null)).toBe(CHAT_PANEL_FALLBACK_LABEL);
|
expect(resolveChatPanelTitle(null, t)).toBe(FALLBACK());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the generic 'Agent' label when undefined", () => {
|
it("falls back to the generic 'Agent' label when undefined", () => {
|
||||||
expect(resolveChatPanelTitle(undefined)).toBe(CHAT_PANEL_FALLBACK_LABEL);
|
expect(resolveChatPanelTitle(undefined, t)).toBe(FALLBACK());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the generic 'Agent' label when the agent label is empty", () => {
|
it("falls back to the generic 'Agent' label when the agent label is empty", () => {
|
||||||
expect(resolveChatPanelTitle("")).toBe(CHAT_PANEL_FALLBACK_LABEL);
|
expect(resolveChatPanelTitle("", t)).toBe(FALLBACK());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the agent label verbatim — does not coerce or relabel valid names", () => {
|
it("uses the agent label verbatim — does not coerce or relabel valid names", () => {
|
||||||
for (const name of ["Mock", "Claude Code", "GPT-5", "amp"]) {
|
for (const name of ["Mock", "Claude Code", "GPT-5", "amp"]) {
|
||||||
expect(resolveChatPanelTitle(name)).toBe(name);
|
expect(resolveChatPanelTitle(name, t)).toBe(name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,14 +27,17 @@ import { TaskPlanPanel } from "./task-plan-panel";
|
|||||||
import { TerminalPanel } from "./terminal-panel";
|
import { TerminalPanel } from "./terminal-panel";
|
||||||
import { TodosContent } from "./todos-panel-content";
|
import { TodosContent } from "./todos-panel-content";
|
||||||
import { VscodePanel } from "./vscode-panel";
|
import { VscodePanel } from "./vscode-panel";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const CHAT_PANEL_FALLBACK_LABEL = "Agent";
|
export function resolveChatPanelTitle(
|
||||||
|
agentLabel: string | null | undefined,
|
||||||
export function resolveChatPanelTitle(agentLabel: string | null | undefined): string {
|
translate: (key: string) => string,
|
||||||
return agentLabel || CHAT_PANEL_FALLBACK_LABEL;
|
): string {
|
||||||
|
return agentLabel || translate("task:panelAgent");
|
||||||
}
|
}
|
||||||
|
|
||||||
function useChatSessionTitle(panelId: string, sessionId: string | null) {
|
function useChatSessionTitle(panelId: string, sessionId: string | null) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const agentLabel = useAppStore((state) => {
|
const agentLabel = useAppStore((state) => {
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
const session = state.taskSessions.items[sessionId];
|
const session = state.taskSessions.items[sessionId];
|
||||||
@@ -49,9 +52,11 @@ function useChatSessionTitle(panelId: string, sessionId: string | null) {
|
|||||||
const parts = profile.label.split(" \u2022 ");
|
const parts = profile.label.split(" \u2022 ");
|
||||||
return parts[1] || parts[0] || profile.label;
|
return parts[1] || parts[0] || profile.label;
|
||||||
});
|
});
|
||||||
|
// `t` is a dependency: a locale switch changes the title with no change to
|
||||||
|
// the panel or the label, and without it the tab keeps the old language.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPanelTitle(panelId, resolveChatPanelTitle(agentLabel));
|
setPanelTitle(panelId, resolveChatPanelTitle(agentLabel, t));
|
||||||
}, [panelId, agentLabel]);
|
}, [panelId, agentLabel, t]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatContent({ panelId, params }: { panelId: string; params: Record<string, unknown> }) {
|
function ChatContent({ panelId, params }: { panelId: string; params: Record<string, unknown> }) {
|
||||||
@@ -127,6 +132,7 @@ function DiffViewerContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChangesContent({ panelId }: { panelId: string }) {
|
function ChangesContent({ panelId }: { panelId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
|
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
|
||||||
const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel);
|
const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel);
|
||||||
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
|
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
|
||||||
@@ -138,9 +144,10 @@ function ChangesContent({ panelId }: { panelId: string }) {
|
|||||||
const totalCount = useSessionChangesCount(activeSessionId);
|
const totalCount = useSessionChangesCount(activeSessionId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes";
|
const title =
|
||||||
|
totalCount > 0 ? `${t("task:panelChanges")} (${totalCount})` : t("task:panelChanges");
|
||||||
setPanelTitle(panelId, title);
|
setPanelTitle(panelId, title);
|
||||||
}, [totalCount, panelId]);
|
}, [totalCount, panelId, t]);
|
||||||
|
|
||||||
const handleEditFile = useCallback(
|
const handleEditFile = useCallback(
|
||||||
(path: string, repo?: string) => openFile(path, repo),
|
(path: string, repo?: string) => openFile(path, repo),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { setPanelTitle, panelPortalManager } from "@/lib/layout/panel-portal-man
|
|||||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||||
import { usePortalSlot } from "@/lib/layout/panel-portal-host";
|
import { usePortalSlot } from "@/lib/layout/panel-portal-host";
|
||||||
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
|
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PORTAL SLOT — generic dockview component that adopts a persistent portal
|
// PORTAL SLOT — generic dockview component that adopts a persistent portal
|
||||||
@@ -304,6 +305,7 @@ function DiffViewerContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChangesContent({ panelId }: { panelId: string }) {
|
function ChangesContent({ panelId }: { panelId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
|
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
|
||||||
const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel);
|
const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel);
|
||||||
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
|
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
|
||||||
@@ -318,9 +320,10 @@ function ChangesContent({ panelId }: { panelId: string }) {
|
|||||||
const totalCount = fileCount + commits.length;
|
const totalCount = fileCount + commits.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes";
|
const title =
|
||||||
|
totalCount > 0 ? `${t("task:panelChanges")} (${totalCount})` : t("task:panelChanges");
|
||||||
setPanelTitle(panelId, title);
|
setPanelTitle(panelId, title);
|
||||||
}, [totalCount, panelId]);
|
}, [totalCount, panelId, t]);
|
||||||
|
|
||||||
const handleEditFile = useCallback(
|
const handleEditFile = useCallback(
|
||||||
(path: string, repo?: string) => openFile(path, repo),
|
(path: string, repo?: string) => openFile(path, repo),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type EnsureSessionErrorInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Matches the backend's exact validation message in task_http_handlers.go / task_ws_handlers.go.
|
// Matches the backend's exact validation message in task_http_handlers.go / task_ws_handlers.go.
|
||||||
|
// i18n-exempt: matches the backend's exact validation message. See the comment above.
|
||||||
const AGENT_PROFILE_MISSING_HINT = "agent_profile_id is required";
|
const AGENT_PROFILE_MISSING_HINT = "agent_profile_id is required";
|
||||||
|
|
||||||
export function describeEnsureError(
|
export function describeEnsureError(
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ function activateNewSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useSessionOptions(taskId: string) {
|
function useSessionOptions(taskId: string) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { sessions, loadSessions } = useTaskSessions(taskId);
|
const { sessions, loadSessions } = useTaskSessions(taskId);
|
||||||
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
|
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -144,10 +145,10 @@ function useSessionOptions(taskId: string) {
|
|||||||
);
|
);
|
||||||
return sorted.map((s, idx) => {
|
return sorted.map((s, idx) => {
|
||||||
const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id);
|
const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id);
|
||||||
const name = profile ? agentProfileDisplayLabel(profile) : "Agent";
|
const name = profile ? agentProfileDisplayLabel(profile) : t("task:panelAgent");
|
||||||
return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name };
|
return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name };
|
||||||
});
|
});
|
||||||
}, [sessions, agentProfiles]);
|
}, [sessions, agentProfiles, t]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMissingCompatibleProfile(
|
function isMissingCompatibleProfile(
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ function useSubtaskDialogState(parentTaskId: string, parentSessions: TaskSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useSessionOptions(taskId: string) {
|
function useSessionOptions(taskId: string) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { sessions, loadSessions } = useTaskSessions(taskId);
|
const { sessions, loadSessions } = useTaskSessions(taskId);
|
||||||
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
|
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,10 +184,10 @@ function useSessionOptions(taskId: string) {
|
|||||||
return sorted.map((s, idx) => {
|
return sorted.map((s, idx) => {
|
||||||
const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id);
|
const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id);
|
||||||
const parts = profile?.label.split(" \u2022 ");
|
const parts = profile?.label.split(" \u2022 ");
|
||||||
const name = parts?.[1] || parts?.[0] || "Agent";
|
const name = parts?.[1] || parts?.[0] || t("task:panelAgent");
|
||||||
return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name };
|
return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name };
|
||||||
});
|
});
|
||||||
}, [sessions, agentProfiles]);
|
}, [sessions, agentProfiles, t]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useExecutorProfiles(
|
function useExecutorProfiles(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { TabRenameInput } from "./tab-rename-input";
|
|||||||
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
|
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
|
||||||
import { SessionTabCloseAction } from "./session-tab-close-action";
|
import { SessionTabCloseAction } from "./session-tab-close-action";
|
||||||
import { useSessionTabDelete } from "./use-session-tab-delete";
|
import { useSessionTabDelete } from "./use-session-tab-delete";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
function useSessionTabState(sessionId: string | undefined) {
|
function useSessionTabState(sessionId: string | undefined) {
|
||||||
const isPrimary = useAppStore((state) => {
|
const isPrimary = useAppStore((state) => {
|
||||||
@@ -137,6 +138,7 @@ function useSessionRenameCommitter(
|
|||||||
currentName: string | null,
|
currentName: string | null,
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
) {
|
) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const appStoreApi = useAppStoreApi();
|
const appStoreApi = useAppStoreApi();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
return useCallback(
|
return useCallback(
|
||||||
@@ -156,8 +158,8 @@ function useSessionRenameCommitter(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("rename session:", error);
|
console.error("rename session:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "Rename failed",
|
title: t("task:renameFailed"),
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
description: error instanceof Error ? error.message : t("common:unknownError"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function useTerminalInit({
|
|||||||
disableStdin: isReadOnlyMode,
|
disableStdin: isReadOnlyMode,
|
||||||
convertEol: isReadOnlyMode,
|
convertEol: isReadOnlyMode,
|
||||||
fontSize: fontSize ?? (isReadOnlyMode ? 12 : 13),
|
fontSize: fontSize ?? (isReadOnlyMode ? 12 : 13),
|
||||||
|
// i18n-exempt: CSS font stack.
|
||||||
fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace',
|
||||||
macOptionIsMeta: true,
|
macOptionIsMeta: true,
|
||||||
theme: getTerminalTheme(terminalRef.current),
|
theme: getTerminalTheme(terminalRef.current),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext, memo } from "react";
|
import { createContext, useContext, memo } from "react";
|
||||||
import { PanelRoot, PanelBody } from "./panel-primitives";
|
import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type TaskArchivedState = {
|
type TaskArchivedState = {
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
@@ -24,15 +25,18 @@ export function useArchivedTaskState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ArchivedPanelPlaceholder = memo(function ArchivedPanelPlaceholder({
|
export const ArchivedPanelPlaceholder = memo(function ArchivedPanelPlaceholder({
|
||||||
message = "Workspace not available - this task is archived",
|
message,
|
||||||
}: {
|
}: {
|
||||||
message?: string;
|
message?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Resolved in the body: a parameter default runs before the hook is in scope.
|
||||||
|
const text = message ?? t("task:workspaceUnavailableArchived");
|
||||||
return (
|
return (
|
||||||
<PanelRoot>
|
<PanelRoot>
|
||||||
<PanelBody>
|
<PanelBody>
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||||
{message}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
</PanelBody>
|
</PanelBody>
|
||||||
</PanelRoot>
|
</PanelRoot>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ function useMoveToStep(store: StoreApi) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useArchiveActions(store: StoreApi) {
|
function useArchiveActions(store: StoreApi) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
|
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
|
||||||
const [archivingTask, setArchivingTask] = useState<{
|
const [archivingTask, setArchivingTask] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -199,7 +200,7 @@ function useArchiveActions(store: StoreApi) {
|
|||||||
const task = findSidebarTask(state, taskId);
|
const task = findSidebarTask(state, taskId);
|
||||||
setArchivingTask({
|
setArchivingTask({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
title: task?.title ?? "this task",
|
title: task?.title ?? t("task:thisTask"),
|
||||||
executorType: task?.primaryExecutorType,
|
executorType: task?.primaryExecutorType,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -239,6 +240,7 @@ function useDeleteActions(
|
|||||||
store: StoreApi,
|
store: StoreApi,
|
||||||
removeTaskFromBoard: ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"],
|
removeTaskFromBoard: ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"],
|
||||||
) {
|
) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { deleteTaskById } = useTaskActions();
|
const { deleteTaskById } = useTaskActions();
|
||||||
const [deletingTask, setDeletingTask] = useState<{
|
const [deletingTask, setDeletingTask] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -253,7 +255,7 @@ function useDeleteActions(
|
|||||||
const task = findSidebarTask(state, taskId);
|
const task = findSidebarTask(state, taskId);
|
||||||
setDeletingTask({
|
setDeletingTask({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
title: task?.title ?? "this task",
|
title: task?.title ?? t("task:thisTask"),
|
||||||
executorType: task?.primaryExecutorType,
|
executorType: task?.primaryExecutorType,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { CloseTerminalConfirmDialog } from "./close-terminal-confirm-dialog";
|
|||||||
import { TabRenameInput } from "./tab-rename-input";
|
import { TabRenameInput } from "./tab-rename-input";
|
||||||
import { markTerminalPanelTerminateClose } from "./dockview-layout-setup";
|
import { markTerminalPanelTerminateClose } from "./dockview-layout-setup";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom dockview tab for terminal panels.
|
* Custom dockview tab for terminal panels.
|
||||||
@@ -56,7 +57,7 @@ function pickDisplayName(
|
|||||||
fallback: string,
|
fallback: string,
|
||||||
): string {
|
): string {
|
||||||
if (shell?.customName && shell.customName !== "") return shell.customName;
|
if (shell?.customName && shell.customName !== "") return shell.customName;
|
||||||
if (shell?.kind === "ordinary") return "Terminal";
|
if (shell?.kind === "ordinary") return t("task:panelTerminal");
|
||||||
if (shell?.label) return shell.label;
|
if (shell?.label) return shell.label;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user