From 10fbe8904cd8fb739cf348805389051d70b980eb Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 00:59:43 +0000 Subject: [PATCH 1/3] feat(task T10): implement via codex --- dashboard/src/v2/ProjectsPage.tsx | 176 ++++++--- .../v2/components/projects/AddProjectCard.tsx | 20 +- .../v2/components/projects/ProjectCard.tsx | 72 ++-- .../projects/__tests__/ProjectCards.test.tsx | 42 +- .../src/v2/components/ui/AddProjectModal.tsx | 201 ++++++---- .../src/v2/components/ui/NewProjectModal.tsx | 67 ++-- .../AddProjectModal.accessibility.test.tsx | 16 +- dashboard/src/v2/i18n/messages/projects.ts | 362 ++++++++++++++++++ .../src/v2/lib/project-card-view-model.ts | 148 ++++--- .../src/v2/lib/projects-page-view-model.ts | 10 +- docs-web/user/dashboard/projects.md | 4 +- docs/dashboard/design-system-projects.md | 6 +- docs/dashboard/internationalization.md | 8 + .../lib/project-card-view-model.test.ts | 31 ++ .../lib/projects-page-view-model.test.ts | 8 +- tests/dashboard/v2/add-project-modal.test.tsx | 88 ++++- tests/dashboard/v2/projects-page.test.tsx | 77 +++- tests/dashboard/v2/top-nav-selectors.test.tsx | 22 +- tests/e2e/projects/project-crud.spec.ts | 32 +- 19 files changed, 1093 insertions(+), 297 deletions(-) create mode 100644 dashboard/src/v2/i18n/messages/projects.ts diff --git a/dashboard/src/v2/ProjectsPage.tsx b/dashboard/src/v2/ProjectsPage.tsx index 5700e39e58..5a93de092a 100644 --- a/dashboard/src/v2/ProjectsPage.tsx +++ b/dashboard/src/v2/ProjectsPage.tsx @@ -20,6 +20,8 @@ import { buildProjectCreationSettingsOverride } from "../lib/settings-updaters.j import { DEFAULT_DASHBOARD_SETTINGS } from "../lib/settings.js"; import { fetchProjectInvocations } from "./lib/invocation-api.js"; import { startProjectSetup } from "./lib/project-api.js"; +import { useDashboardI18n } from "./i18n/context.js"; +import { projectMessages } from "./i18n/messages/projects.js"; import { buildProjectsPageViewModel, PROJECT_FILTER_DEFINITIONS, @@ -36,22 +38,26 @@ const createDefaultSetupOptions = (): ProjectSetupOptions => ({ }); const SETUP_OPTIONS = [ - { key: "agents", label: "Agents", description: "Specialists and routing." }, - { key: "quicksprints", label: "Quicksprints", description: "Sprint templates." }, - { key: "previewScript", label: "Preview Script", description: "Container startup." }, - { key: "ci", label: "CI", description: "Basic checks." }, - { key: "techstack", label: "Techstack", description: "Detect and assign from manifests." }, - { key: "docs", label: "Docs", description: "Embed repository docs into Knowledge docs.", icon: BookOpen }, + { key: "agents", labelKey: "setupAgents", descriptionKey: "setupAgentsDescription" }, + { key: "quicksprints", labelKey: "setupQuicksprints", descriptionKey: "setupQuicksprintsDescription" }, + { key: "previewScript", labelKey: "setupPreviewScript", descriptionKey: "setupPreviewScriptDescription" }, + { key: "ci", labelKey: "setupCi", descriptionKey: "setupCiDescription" }, + { key: "techstack", labelKey: "setupTechstack", descriptionKey: "setupTechstackDescription" }, + { key: "docs", labelKey: "setupDocs", descriptionKey: "setupDocsDescription", icon: BookOpen }, ] as const; export const ProjectsPage: FunctionComponent = () => { const navigate = useNavigate(); + const { formatNumber, translate, translatePlural } = useDashboardI18n(); const [showModal, setShowModal] = useState(false); const [modalSourceType, setModalSourceType] = useState("local"); const [setupProjectId, setSetupProjectId] = useState(null); const [runningSetupProjectIds, setRunningSetupProjectIds] = useState>(() => new Set()); const [setupInvocationByProjectId, setSetupInvocationByProjectId] = useState>({}); const [setupError, setSetupError] = useState(null); + const [deleteProjectId, setDeleteProjectId] = useState(null); + const [isDeletingProject, setIsDeletingProject] = useState(false); + const [deleteError, setDeleteError] = useState(null); const [setupOptions, setSetupOptions] = useState(() => createDefaultSetupOptions()); const [activeFilter, setActiveFilter] = useState("All"); const { @@ -107,7 +113,7 @@ export const ProjectsPage: FunctionComponent = () => { setRunningSetupProjectIds((previous) => new Set(previous).add(projectId)); addToast({ type: "info", - message: `Starting project initialization for ${projectName}. The invocation rail will open as soon as tracking is ready.`, + message: translate(projectMessages, "setupStarting", { name: projectName }), autoDismissMs: 7000, }); @@ -119,10 +125,13 @@ export const ProjectsPage: FunctionComponent = () => { })); addToast({ type: "info", - message: `Project initialization is running for ${projectName}. Invocation ${started.invocationId.slice(0, 8)} is available now.`, + message: translate(projectMessages, "setupRunning", { + name: projectName, + invocation: started.invocationId.slice(0, 8), + }), autoDismissMs: 0, action: { - label: "Open invocation", + label: translate(projectMessages, "openInvocation"), onClick: () => openInvocation(started.invocationId), }, }); @@ -133,14 +142,14 @@ export const ProjectsPage: FunctionComponent = () => { }) .then(({ started, invocation }) => { if (invocation.status === "failed") { - throw new Error(invocation.lastErrorMessage || "Project initialization invocation failed."); + throw new Error(invocation.lastErrorMessage || translate(projectMessages, "setupInvocationFailed")); } addToast({ type: "success", - message: `Project initialization finished for ${projectName}. Review the invocation output for generated artifacts.`, + message: translate(projectMessages, "setupFinished", { name: projectName }), autoDismissMs: 9000, action: { - label: "Open invocation", + label: translate(projectMessages, "openInvocation"), onClick: () => openInvocation(started.invocationId), }, }); @@ -149,7 +158,7 @@ export const ProjectsPage: FunctionComponent = () => { const message = setupFailure instanceof Error ? setupFailure.message : String(setupFailure); addToast({ type: "error", - message: `Project initialization failed for ${projectName}: ${message}`, + message: translate(projectMessages, "setupFailed", { name: projectName, message }), autoDismissMs: 0, }); }) @@ -201,6 +210,7 @@ export const ProjectsPage: FunctionComponent = () => { }; const activeSetupProject = sources.find((source) => source.id === setupProjectId) ?? null; + const activeDeleteProject = sources.find((source) => source.id === deleteProjectId) ?? null; const isActiveSetupRunning = activeSetupProject ? runningSetupProjectIds.has(activeSetupProject.id) : false; @@ -219,9 +229,35 @@ export const ProjectsPage: FunctionComponent = () => { setSetupError(null); }; + const openDeleteDialog = (projectId: string) => { + setDeleteProjectId(projectId); + setDeleteError(null); + }; + + const closeDeleteDialog = () => { + if (isDeletingProject) return; + setDeleteProjectId(null); + setDeleteError(null); + }; + + const handleDeleteProject = async () => { + if (!activeDeleteProject || isDeletingProject) return; + setIsDeletingProject(true); + setDeleteError(null); + try { + await deleteProject(activeDeleteProject.id); + setDeleteProjectId(null); + } catch (deleteFailure) { + const message = deleteFailure instanceof Error ? deleteFailure.message : String(deleteFailure); + setDeleteError(translate(projectMessages, "deleteFailed", { message })); + } finally { + setIsDeletingProject(false); + } + }; + return ( <> - + + + ) : null} + + {activeDeleteProject ? ( +
+
+

+ {translate(projectMessages, "confirmDeleteTitle", { name: activeDeleteProject.name })} +

+

+ {translate(projectMessages, "confirmDeleteDescription")} +

+ {deleteError ? ( +

+ {deleteError} +

+ ) : null} +
+ +
diff --git a/dashboard/src/v2/components/projects/AddProjectCard.tsx b/dashboard/src/v2/components/projects/AddProjectCard.tsx index b8e807c54f..6dd241677c 100644 --- a/dashboard/src/v2/components/projects/AddProjectCard.tsx +++ b/dashboard/src/v2/components/projects/AddProjectCard.tsx @@ -1,23 +1,29 @@ import type { FunctionComponent } from "preact"; import { Plus } from "lucide-preact"; +import { useDashboardI18n } from "../../i18n/context.js"; +import { projectMessages } from "../../i18n/messages/projects.js"; export interface AddProjectCardProps { onClick: () => void; } -export const AddProjectCard: FunctionComponent = ({ onClick }) => ( - -); + + ); +}; diff --git a/dashboard/src/v2/components/projects/ProjectCard.tsx b/dashboard/src/v2/components/projects/ProjectCard.tsx index b58a9e6683..8f23999163 100644 --- a/dashboard/src/v2/components/projects/ProjectCard.tsx +++ b/dashboard/src/v2/components/projects/ProjectCard.tsx @@ -15,6 +15,8 @@ import { import type { Source, SourceStatus } from "../../types.js"; import type { ProjectCardDisplayValue } from "../../types.js"; import { buildProjectCardViewModel } from "../../lib/project-card-view-model.js"; +import { useDashboardI18n } from "../../i18n/context.js"; +import { projectMessages } from "../../i18n/messages/projects.js"; import { StatusDot } from "../ui/StatusDot.js"; export interface ProjectCardProps { @@ -29,11 +31,11 @@ export interface ProjectCardProps { onSettings: () => void; } -const STATUS_LABELS: Record = { - running: "Running", - failed: "Failed", - intervention: "Needs review", - idle: "Idle", +const STATUS_MESSAGE_KEYS: Record = { + running: "statusRunning", + failed: "statusFailed", + intervention: "statusNeedsReview", + idle: "statusIdle", }; const STATUS_TEXT_CLASSES: Record = { @@ -123,16 +125,24 @@ export const ProjectCard: FunctionComponent = ({ onOpenInvocation, onSettings, }) => { - const viewModel = useMemo(() => buildProjectCardViewModel(source), [source]); + const { locale, formatNumber, translate } = useDashboardI18n(); + const viewModel = useMemo(() => buildProjectCardViewModel(source, locale), [locale, source]); const location = viewModel.gitUrl.isEmpty ? viewModel.localDirectory : viewModel.gitUrl; - const locationLabel = viewModel.gitUrl.isEmpty ? "Path" : "Repository"; - const lastRunStatus = viewModel.lastRunStatus.isEmpty ? "No runs yet" : viewModel.lastRunStatus.value; + const locationLabel = translate(projectMessages, viewModel.gitUrl.isEmpty ? "path" : "repository"); + const lastRunStatus = viewModel.lastRunStatus.isEmpty + ? translate(projectMessages, "noRunsYet") + : viewModel.lastRunStatus.value; const completion = viewModel.taskCompletion.percentage ?? 0; - const selectionLabel = isSelected ? `Selected project: ${source.name}` : `Select project: ${source.name}`; + const selectionLabel = translate( + projectMessages, + isSelected ? "selectedProject" : "selectProject", + { name: source.name }, + ); + const statusLabel = translate(projectMessages, STATUS_MESSAGE_KEYS[source.status]); return (
= ({ {isSelected ? ( ) : null} - - {STATUS_LABELS[source.status]} + + {statusLabel} {viewModel.sourceBadge.label} @@ -190,12 +200,12 @@ export const ProjectCard: FunctionComponent = ({ icon={viewModel.gitUrl.isEmpty ?
); }; -const Stat: FunctionComponent<{ label: string; value: number }> = ({ label, value }) => ( +const Stat: FunctionComponent<{ label: string; value: string }> = ({ label, value }) => ( {value} {label} diff --git a/dashboard/src/v2/components/projects/__tests__/ProjectCards.test.tsx b/dashboard/src/v2/components/projects/__tests__/ProjectCards.test.tsx index bde77a9290..c58f3b8e38 100644 --- a/dashboard/src/v2/components/projects/__tests__/ProjectCards.test.tsx +++ b/dashboard/src/v2/components/projects/__tests__/ProjectCards.test.tsx @@ -1,11 +1,14 @@ /** @vitest-environment jsdom */ import { cleanup, fireEvent, render, screen } from "@testing-library/preact"; +import type { ComponentChildren } from "preact"; import * as matchers from "@testing-library/jest-dom/matchers"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Source } from "../../../types.js"; import { AddProjectCard } from "../AddProjectCard.js"; import { ProjectCard, type ProjectCardProps } from "../ProjectCard.js"; +import { DashboardI18nProvider } from "../../../i18n/context.js"; +import type { DashboardLocale } from "../../../i18n/locales.js"; expect.extend(matchers); @@ -52,6 +55,12 @@ function createProps(overrides: Partial = {}): ProjectCardProp }; } +const withLocale = (children: ComponentChildren, locale: DashboardLocale = "en") => ( + + {children} + +); + afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -60,7 +69,7 @@ afterEach(() => { describe("ProjectCard", () => { it("selects the project from the card selection surface", () => { const props = createProps(); - render(); + render(withLocale()); fireEvent.click(screen.getByRole("button", { name: "Select project: Project One" })); @@ -70,7 +79,7 @@ describe("ProjectCard", () => { it("supports native keyboard activation", async () => { const user = userEvent.setup(); const props = createProps(); - render(); + render(withLocale()); const selectSurface = screen.getByRole("button", { name: "Select project: Project One" }); selectSurface.focus(); @@ -82,7 +91,7 @@ describe("ProjectCard", () => { it("isolates setup, settings, and delete actions from selection", () => { const props = createProps(); - render(); + render(withLocale()); fireEvent.click(screen.getByRole("button", { name: "Setup project" })); fireEvent.click(screen.getByRole("button", { name: "Project settings" })); @@ -96,7 +105,7 @@ describe("ProjectCard", () => { it("opens an available setup invocation without selecting the project", () => { const props = createProps({ isSettingUp: true, setupInvocationId: "invocation-123" }); - render(); + render(withLocale()); fireEvent.click(screen.getByRole("button", { name: "Open setup invocation" })); @@ -109,13 +118,13 @@ describe("ProjectCard", () => { const longName = "A project name long enough to overflow a narrow mobile project card surface"; const longRepository = "https://example.com/organization/with-a-very-long-name/repository-with-a-very-long-name.git"; const longBranch = "feature/a-branch-name-that-must-never-force-horizontal-page-overflow"; - const { rerender } = render( + const { rerender } = render(withLocale( , - ); + )); expect(screen.getByTestId("project-name")).toHaveClass("truncate"); expect(screen.getByTestId("project-name")).toHaveAttribute("title", longName); @@ -125,12 +134,12 @@ describe("ProjectCard", () => { expect(screen.getByTestId("project-branch")).toHaveAttribute("title", longBranch); const longPath = "/workspace/a/local/path/with/many/nested/directories/that-must-remain-inside-the-card"; - rerender(); + rerender(withLocale()); expect(screen.getByTestId("project-location")).toHaveAttribute("title", longPath); }); it("exposes stable selected and running states with static visual cues", () => { - render(); + render(withLocale()); const card = screen.getByRole("article", { name: "Project: Project One" }); expect(card).toHaveAttribute("data-selected", "true"); @@ -138,25 +147,36 @@ describe("ProjectCard", () => { expect(card).toHaveClass("border-signal-500/55"); expect(screen.getByRole("button", { name: "Selected project: Project One" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("status", { name: "Project One is selected" })).toBeInTheDocument(); - expect(screen.getByRole("img", { name: "Status: running" })).toBeInTheDocument(); + expect(screen.getByLabelText("Status: Running")).toBeInTheDocument(); expect(screen.getByText("Running")).toBeInTheDocument(); }); it("renders view-model task counts and completion", () => { - render(); + render(withLocale()); expect(screen.getByText("75%")).toBeInTheDocument(); expect(screen.getByRole("progressbar", { name: "Project One task completion" })).toHaveAttribute("aria-valuenow", "75"); expect(screen.getByText("Open").previousElementSibling).toHaveTextContent("2"); expect(screen.getByText("Done").previousElementSibling).toHaveTextContent("6"); }); + + it("localizes card chrome and metadata while preserving project values", () => { + render(withLocale(, "de")); + + expect(screen.getByRole("article", { name: "Projekt: Project One" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Projekt auswählen: Project One" })).toBeInTheDocument(); + expect(screen.getByText("Inaktiv")).toBeInTheDocument(); + expect(screen.getByText("4. Jan. 2026, 5:06")).toBeInTheDocument(); + expect(screen.getByText("/workspace/project-one")).toBeInTheDocument(); + expect(screen.getByText("completed")).toBeInTheDocument(); + }); }); describe("AddProjectCard", () => { it("provides a full-height keyboard-reachable Add Project entry point", async () => { const user = userEvent.setup(); const onClick = vi.fn(); - render(); + render(withLocale()); const addProject = screen.getByRole("button", { name: "Add Project" }); expect(addProject).toHaveClass("h-full", "min-h-[390px]"); diff --git a/dashboard/src/v2/components/ui/AddProjectModal.tsx b/dashboard/src/v2/components/ui/AddProjectModal.tsx index 91ecf537d2..0d7e6e92d8 100644 --- a/dashboard/src/v2/components/ui/AddProjectModal.tsx +++ b/dashboard/src/v2/components/ui/AddProjectModal.tsx @@ -12,6 +12,8 @@ import { DEFAULT_DASHBOARD_SETTINGS } from "../../../lib/settings.js"; import { useReducedMotion } from "../../hooks/use-reduced-motion.js"; import { useGsapInteractionTokens } from "../../lib/motion/constants.js"; import { useInteractionTokens } from "../../lib/motion/tokens.js"; +import { useDashboardI18n } from "../../i18n/context.js"; +import { projectMessages } from "../../i18n/messages/projects.js"; export type SourceType = 'local' | 'git' | 'new_project'; @@ -83,6 +85,7 @@ function focusFirstInvalidField(formId: string, scrollContainerId: string, reduc } export const AddProjectModal: FunctionComponent = ({ onClose, onAdd, initialSourceType, quickActionDefaults }) => { + const { formatNumber, translate, translatePlural } = useDashboardI18n(); const fieldsRef = useRef(null); const transitionSurfaceRef = useRef(null); const previousTransitionKeyRef = useRef(null); @@ -126,23 +129,23 @@ export const AddProjectModal: FunctionComponent = ({ onClo ?? DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.defaultTechstackId; const newProjectApplicationKind = quickActionDefaults?.applicationKind ?? null; const quickActionContextLabel = quickActionDefaults?.applicationKind === 'web' - ? 'Web App' + ? translate(projectMessages, "webApp") : quickActionDefaults?.applicationKind === 'desktop' - ? 'Desktop App' + ? translate(projectMessages, "desktopApp") : null; const validationErrors = useMemo(() => { const errors: Record = {}; - if (!name.trim()) errors.name = "Project Name is required."; + if (!name.trim()) errors.name = translate(projectMessages, "projectNameRequired"); if (sourceType === 'git' && !gitUrl.trim()) { - errors.path = "Repository URL is required."; + errors.path = translate(projectMessages, "repositoryUrlRequired"); } if (sourceType === 'new_project' && newInitMode === 'new-remote' && !gitUrlSlug.trim()) { - errors.slug = "Git URL Slug is required."; + errors.slug = translate(projectMessages, "gitUrlSlugRequired"); } return errors; - }, [gitUrl, gitUrlSlug, localPath, name, newInitMode, sourceType]); + }, [gitUrl, gitUrlSlug, localPath, name, newInitMode, sourceType, translate]); const sourceTransitionKey = `${sourceType}:${newInitMode}:${showSetupOptions ? 'setup' : 'details'}`; @@ -236,7 +239,9 @@ export const AddProjectModal: FunctionComponent = ({ onClo if (Object.keys(validationErrors).length > 0) { setTouched({ name: true, path: sourceType === 'git', slug: sourceType === 'new_project' && newInitMode === 'new-remote' }); - setValidationSummary(`Review required fields: ${Object.values(validationErrors).join(" ")}`); + setValidationSummary(translate(projectMessages, "reviewRequiredFields", { + errors: Object.values(validationErrors).join(" "), + })); setSubmitError(null); setTimeout(() => focusFirstInvalidField('add-project-form', 'add-project-form-body', reducedMotion), 0); return; @@ -288,7 +293,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo setCloneDir(result.filePath); } clearFeedback(); - setDirectorySelectionMessage(`Selected directory: ${result.filePath}`); + setDirectorySelectionMessage(translate(projectMessages, "selectedDirectory", { path: result.filePath })); setActiveDirectoryPickerTarget(null); return; } catch (err) { @@ -308,7 +313,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo setCloneDir(directoryListing.currentPath); } clearFeedback(); - setDirectorySelectionMessage(`Selected directory: ${directoryListing.currentPath}`); + setDirectorySelectionMessage(translate(projectMessages, "selectedDirectory", { path: directoryListing.currentPath })); setActiveDirectoryPickerTarget(null); }; @@ -323,15 +328,23 @@ export const AddProjectModal: FunctionComponent = ({ onClo const pickerId = target === 'localPath' ? "add-project-directory-picker" : "add-project-clone-directory-picker"; const currentPath = directoryListing?.currentPath || pendingDirectoryPath || ""; const loadingLabel = pendingDirectoryPath - ? `Loading ${pendingDirectoryPath}` - : "Loading directories"; + ? translate(projectMessages, "loadingPath", { path: pendingDirectoryPath }) + : translate(projectMessages, "loadingDirectories"); const statusMessage = directoryPickerError - ? `Directory load failed: ${directoryPickerError}` + ? translate(projectMessages, "directoryLoadFailed", { message: directoryPickerError }) : isDirectoryPickerLoading ? loadingLabel : directoryListing - ? `${directoryListing.directories.length} child director${directoryListing.directories.length === 1 ? "y" : "ies"} in ${directoryListing.currentPath}.` - : "Directory picker ready."; + ? translatePlural( + projectMessages, + "childDirectories", + directoryListing.directories.length, + { + count: formatNumber(directoryListing.directories.length), + path: directoryListing.currentPath, + }, + ) + : translate(projectMessages, "directoryPickerReady"); return (
= ({ onClo onClick={() => directoryListing?.parentPath && void loadDirectory(target, directoryListing.parentPath)} disabled={!directoryListing?.parentPath || isDirectoryPickerLoading} className="flex h-8 w-8 items-center justify-center rounded-xl bg-white text-slate-500 shadow-sm transition-all hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white/[0.06] dark:text-slate-300 dark:hover:text-white" - aria-label="Go to parent directory" - title="Go up" + aria-label={translate(projectMessages, "goToParentDirectory")} + title={translate(projectMessages, "goUp")} > @@ -354,8 +367,8 @@ export const AddProjectModal: FunctionComponent = ({ onClo onClick={() => void loadDirectory(target, directoryListing?.homePath)} disabled={isDirectoryPickerLoading} className="flex h-8 w-8 items-center justify-center rounded-xl bg-white text-slate-500 shadow-sm transition-all hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white/[0.06] dark:text-slate-300 dark:hover:text-white" - aria-label="Go to home directory" - title="Home" + aria-label={translate(projectMessages, "goToHomeDirectory")} + title={translate(projectMessages, "home")} > @@ -368,16 +381,20 @@ export const AddProjectModal: FunctionComponent = ({ onClo disabled={isDirectoryPickerLoading} aria-busy={isDirectoryPickerLoading} className="flex h-8 w-8 items-center justify-center rounded-xl bg-white text-slate-500 shadow-sm transition-all hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white/[0.06] dark:text-slate-300 dark:hover:text-white" - aria-label="Refresh directories" - title="Refresh" + aria-label={translate(projectMessages, "refreshDirectories")} + title={translate(projectMessages, "refresh")} > - <>
= ({ onClo
) : (
- No child directories in {directoryListing?.currentPath || "the current path"} + {translate(projectMessages, "noChildDirectories", { + path: directoryListing?.currentPath || translate(projectMessages, "directoryPickerPath"), + })}
)}
@@ -435,12 +454,12 @@ export const AddProjectModal: FunctionComponent = ({ onClo }; const setupOptionRows = [ - { key: "agents", label: "Agents", description: "Specialist agents and orchestrator routing.", icon: Bot }, - { key: "quicksprints", label: "Quicksprints", description: "Repository-specific sprint templates.", icon: Workflow }, - { key: "previewScript", label: "Preview Script", description: "Container startup script for browser previews.", icon: PlaySquare }, - { key: "ci", label: "CI", description: "Basic GitHub/GitLab error-checking pipelines.", icon: ShieldCheck }, - { key: "techstack", label: "Techstack", description: "Detect and assign a project stack from manifests.", icon: Layers3 }, - { key: "docs", label: "Docs", description: "Embed repository docs into Knowledge docs.", icon: BookOpen }, + { key: "agents", label: translate(projectMessages, "setupAgents"), description: translate(projectMessages, "setupAgentsLongDescription"), icon: Bot }, + { key: "quicksprints", label: translate(projectMessages, "setupQuicksprints"), description: translate(projectMessages, "setupQuicksprintsLongDescription"), icon: Workflow }, + { key: "previewScript", label: translate(projectMessages, "setupPreviewScript"), description: translate(projectMessages, "setupPreviewScriptLongDescription"), icon: PlaySquare }, + { key: "ci", label: translate(projectMessages, "setupCi"), description: translate(projectMessages, "setupCiLongDescription"), icon: ShieldCheck }, + { key: "techstack", label: translate(projectMessages, "setupTechstack"), description: translate(projectMessages, "setupTechstackLongDescription"), icon: Layers3 }, + { key: "docs", label: translate(projectMessages, "setupDocs"), description: translate(projectMessages, "setupDocsDescription"), icon: BookOpen }, ] as const; return ( @@ -458,7 +477,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo {/* ── Left decorative panel ── */}
- ADD + {translate(projectMessages, "addProject")}
@@ -467,12 +486,16 @@ export const AddProjectModal: FunctionComponent = ({ onClo
- New Project + {translate(projectMessages, "newProject")}
-
Source
+
{translate(projectMessages, "source")}
- {sourceType === 'new_project' ? 'New Project' : sourceType === 'git' ? 'Git Repo' : 'Local Project'} + {sourceType === 'new_project' + ? translate(projectMessages, "newProject") + : sourceType === 'git' + ? translate(projectMessages, "gitRepo") + : translate(projectMessages, "localProject")} {quickActionContextLabel ? `: ${quickActionContextLabel}` : ''}
@@ -485,22 +508,32 @@ export const AddProjectModal: FunctionComponent = ({ onClo

- {quickActionContextLabel ? `Create ${quickActionContextLabel}.` : 'Add Project.'} + {quickActionDefaults?.applicationKind === "web" + ? translate(projectMessages, "createWebApp") + : quickActionDefaults?.applicationKind === "desktop" + ? translate(projectMessages, "createDesktopApp") + : `${translate(projectMessages, "addProject")}.`}

{quickActionContextLabel - ? `Initialize a new ${quickActionContextLabel.toLowerCase()} repository with explicit project techstack settings` - : 'Connect a local directory or remote repository'} + ? translate(projectMessages, "initializeNewAppDescription", { kind: quickActionContextLabel }) + : translate(projectMessages, "connectRepositoryDescription")}

- {sourceType === 'new_project' ? 'New Project selected' : sourceType === 'git' ? 'Git Repo selected' : 'Local Project selected'} - {quickActionContextLabel ? `. ${quickActionContextLabel} context selected.` : ''} - {showSetupOptions ? '. Setup Options step.' : ''} + {translate(projectMessages, "sourceSelected", { + source: sourceType === 'new_project' + ? translate(projectMessages, "newProject") + : sourceType === 'git' + ? translate(projectMessages, "gitRepo") + : translate(projectMessages, "localProject"), + })} + {quickActionContextLabel ? `. ${translate(projectMessages, "contextSelected", { context: quickActionContextLabel })}` : ''} + {showSetupOptions ? `. ${translate(projectMessages, "setupOptionsStep")}` : ''}
))}
@@ -596,8 +633,8 @@ export const AddProjectModal: FunctionComponent = ({ onClo {sourceType === 'local' && (
= ({ onClo className="flex shrink-0 items-center justify-center gap-2 rounded-[1.15rem] border border-black/[0.06] bg-void-900 px-4 py-3 text-xs font-black uppercase tracking-[0.14em] text-white transition-all duration-250 hover:-translate-y-px hover:bg-void-800 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ember-500 dark:border-white/[0.08] dark:bg-white/[0.08] dark:text-white dark:hover:bg-white/[0.12]" aria-expanded={activeDirectoryPickerTarget === 'localPath'} aria-controls="add-project-directory-picker" - title="Browse directories" + title={translate(projectMessages, "browseDirectories")} > - Browse + {translate(projectMessages, "browse")}
{renderDirectoryPicker('localPath')} @@ -643,7 +680,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo <>
= ({ onClo
= ({ onClo className="flex shrink-0 items-center justify-center gap-2 rounded-[1.15rem] border border-black/[0.06] bg-void-900 px-4 py-3 text-xs font-black uppercase tracking-[0.14em] text-white transition-all duration-250 hover:-translate-y-px hover:bg-void-800 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ember-500 dark:border-white/[0.08] dark:bg-white/[0.08] dark:text-white dark:hover:bg-white/[0.12]" aria-expanded={activeDirectoryPickerTarget === 'cloneDir'} aria-controls="add-project-clone-directory-picker" - title="Browse clone directory" + title={translate(projectMessages, "browseCloneDirectory")} > - Browse + {translate(projectMessages, "browse")}
{renderDirectoryPicker('cloneDir')} @@ -727,10 +764,10 @@ export const AddProjectModal: FunctionComponent = ({ onClo - Initialize with Project Setup Agent + {translate(projectMessages, "initializeWithSetup")} - Research the codebase after creation and generate project-specific agents, routing, quicksprints, preview startup, basic CI, and a detected techstack. + {translate(projectMessages, "initializeWithSetupDescription")} @@ -741,10 +778,10 @@ export const AddProjectModal: FunctionComponent = ({ onClo
- Setup Scope + {translate(projectMessages, "setupScope")}
- Choose project assets + {translate(projectMessages, "chooseProjectAssets")}
@@ -782,7 +819,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo {key === "previewScript" && ( e.stopPropagation()} /> )} @@ -802,7 +839,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo <>
- Init Mode + {translate(projectMessages, "initMode")}
@@ -841,8 +878,8 @@ export const AddProjectModal: FunctionComponent = ({ onClo {newInitMode === 'new-local' ? (
= ({ onClo className="flex shrink-0 items-center justify-center gap-2 rounded-[1.15rem] border border-black/[0.06] bg-void-900 px-4 py-3 text-xs font-black uppercase tracking-[0.14em] text-white transition-all duration-250 hover:-translate-y-px hover:bg-void-800 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ember-500 dark:border-white/[0.08] dark:bg-white/[0.08] dark:text-white dark:hover:bg-white/[0.12]" aria-expanded={activeDirectoryPickerTarget === 'localPath'} aria-controls="add-project-directory-picker" - title="Browse directories" + title={translate(projectMessages, "browseDirectories")} > - Browse + {translate(projectMessages, "browse")}
{renderDirectoryPicker('localPath')} @@ -885,7 +922,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo <>
= ({ onClo
- Provider detection is optional here; both buttons are shown. + {translate(projectMessages, "providerDetectionHint")}
@@ -945,7 +982,7 @@ export const AddProjectModal: FunctionComponent = ({ onClo
- Visibility + {translate(projectMessages, "visibility")}
@@ -990,26 +1027,28 @@ export const AddProjectModal: FunctionComponent = ({ onClo onClick={handleClose} disabled={isSubmitting} aria-describedby={isSubmitting ? "add-project-submit-disabled-reason" : undefined} - title={isSubmitting ? "Project creation is in progress." : undefined} + title={isSubmitting ? translate(projectMessages, "projectCreationInProgress") : undefined} className="text-sm font-semibold text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-all active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ember-500 rounded w-full sm:w-auto py-2 sm:py-0 disabled:cursor-not-allowed disabled:opacity-50" > - Cancel + {translate(projectMessages, "cancel")} - Project creation is in progress. Wait for it to finish or retry if it fails. + {translate(projectMessages, "projectCreationDisabledReason")}
diff --git a/dashboard/src/v2/components/ui/NewProjectModal.tsx b/dashboard/src/v2/components/ui/NewProjectModal.tsx index 4943905e4c..b649cdf888 100644 --- a/dashboard/src/v2/components/ui/NewProjectModal.tsx +++ b/dashboard/src/v2/components/ui/NewProjectModal.tsx @@ -16,6 +16,8 @@ import { useFocusTrap } from "../../hooks/use-focus-trap.js"; import { useReducedMotion } from "../../hooks/use-reduced-motion.js"; import { MODAL_MOTION } from "../../lib/motion/modal-motion.js"; import type { AvailableGitProviders } from "../../lib/project-api.js"; +import { useDashboardI18n } from "../../i18n/context.js"; +import { projectMessages } from "../../i18n/messages/projects.js"; interface NewProjectModalProps { onClose: () => void; @@ -39,6 +41,7 @@ const detailInputClass = `mt-2.5 ${detailInputSurfaceClass}`; const modalMinHeight = "min(640px, calc(100vh - 2rem))"; export const NewProjectModal: FunctionComponent = ({ onClose, onAdd, providers }) => { + const { translate } = useDashboardI18n(); const cardRef = useRef(null); const fieldsRef = useRef(null); @@ -59,15 +62,15 @@ export const NewProjectModal: FunctionComponent = ({ onClo const validationErrors = useMemo(() => { const errors: Record = {}; - if (!name.trim()) errors.name = "Project Name is required."; + if (!name.trim()) errors.name = translate(projectMessages, "projectNameRequired"); if (initMode === 'new-local' && !localPath.trim()) { - errors.path = "Directory Path is required."; + errors.path = translate(projectMessages, "directoryPathRequired"); } else if (initMode === 'new-remote' && !repoName.trim()) { - errors.path = "Repository Name is required."; + errors.path = translate(projectMessages, "repositoryNameRequired"); } return errors; - }, [name, initMode, localPath, repoName]); + }, [name, initMode, localPath, repoName, translate]); useLayoutEffect(() => { const d_backdrop = reducedMotion ? 0 : MODAL_MOTION.backdrop.duration; @@ -184,7 +187,7 @@ export const NewProjectModal: FunctionComponent = ({ onClo {/* ── Left decorative panel ── */}
- NEW + {translate(projectMessages, "newProject")}
@@ -193,12 +196,12 @@ export const NewProjectModal: FunctionComponent = ({ onClo
- Initialize + {translate(projectMessages, "initialize")}
-
Mode
+
{translate(projectMessages, "mode")}
- {initMode === 'new-local' ? 'Local Repo' : 'Remote Repo'} + {translate(projectMessages, initMode === 'new-local' ? "localRepo" : "remoteRepo")}
@@ -210,15 +213,15 @@ export const NewProjectModal: FunctionComponent = ({ onClo

- New Project. + {translate(projectMessages, "newProjectTitle")}

- Initialize a git repo locally or on a remote provider + {translate(projectMessages, "initializeRepositoryDescription")}

@@ -296,7 +299,7 @@ export const NewProjectModal: FunctionComponent = ({ onClo {initMode === 'new-local' ? (
= ({ onClo type="button" onClick={() => handleOpenDirectoryPicker('localPath')} className="flex shrink-0 items-center justify-center gap-2 rounded-[1.15rem] border border-black/[0.06] bg-void-900 px-4 py-3 text-xs font-black uppercase tracking-[0.14em] text-white transition-all duration-250 hover:-translate-y-px hover:bg-void-800 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ember-500 dark:border-white/[0.08] dark:bg-white/[0.08] dark:text-white dark:hover:bg-white/[0.12]" - title="Browse directories" + title={translate(projectMessages, "browseDirectories")} > - Browse + {translate(projectMessages, "browse")}
{validationErrors.path && touched.path &&
{validationErrors.path}
} @@ -331,14 +334,14 @@ export const NewProjectModal: FunctionComponent = ({ onClo {noProviders ? (
- No git providers configured. Add a GitHub or GitLab token in Settings. + {translate(projectMessages, "noGitProviders")}
) : ( <> {providers.github && providers.gitlab && (
- Provider + {translate(projectMessages, "provider")}
- Visibility + {translate(projectMessages, "visibility")}
@@ -461,7 +464,7 @@ export const NewProjectModal: FunctionComponent = ({ onClo onClick={handleClose} className="text-sm font-semibold text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-all active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ember-500 rounded" > - Cancel + {translate(projectMessages, "cancel")}
diff --git a/dashboard/src/v2/components/ui/__tests__/AddProjectModal.accessibility.test.tsx b/dashboard/src/v2/components/ui/__tests__/AddProjectModal.accessibility.test.tsx index c50119f480..b6557979a0 100644 --- a/dashboard/src/v2/components/ui/__tests__/AddProjectModal.accessibility.test.tsx +++ b/dashboard/src/v2/components/ui/__tests__/AddProjectModal.accessibility.test.tsx @@ -1,9 +1,11 @@ /** @vitest-environment happy-dom */ import { h } from "preact"; +import type { ComponentChildren } from "preact"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; import { afterEach, expect, test, describe, vi } from "vitest"; import { AddProjectModal } from "../AddProjectModal.js"; import * as matchers from '@testing-library/jest-dom/matchers'; +import { DashboardI18nProvider } from "../../../i18n/context.js"; expect.extend(matchers); describe("AddProjectModal Accessibility", () => { @@ -11,8 +13,12 @@ describe("AddProjectModal Accessibility", () => { cleanup(); }); + const renderWithI18n = (children: ComponentChildren) => render( + {children}, + ); + const revealImportedSetupOptions = async () => { - render( {}} onAdd={() => {}} initialSourceType="local" />); + renderWithI18n( {}} onAdd={() => {}} initialSourceType="local" />); const nameInput = screen.getByLabelText(/Project Name/i); fireEvent.input(nameInput, { target: { value: "Imported App" } }); @@ -24,7 +30,7 @@ describe("AddProjectModal Accessibility", () => { }; test("renders with accessible name and structure", () => { - const { container } = render( {}} onAdd={() => {}} initialSourceType="local" />); + const { container } = renderWithI18n( {}} onAdd={() => {}} initialSourceType="local" />); const dialogs = screen.getAllByRole("dialog"); expect(dialogs[0]).toHaveAttribute("aria-labelledby", "add-project-modal-title"); @@ -34,7 +40,7 @@ describe("AddProjectModal Accessibility", () => { }); test("form inputs have associated labels and handle validation errors", async () => { - const { container } = render( {}} onAdd={() => {}} initialSourceType="local" />); + const { container } = renderWithI18n( {}} onAdd={() => {}} initialSourceType="local" />); // Check for Local Path input const pathInput = document.getElementById("add-project-path"); @@ -46,7 +52,7 @@ describe("AddProjectModal Accessibility", () => { }); test("invalid git submit marks errors, focuses first invalid field, and scrolls form body", async () => { - render( {}} onAdd={() => {}} initialSourceType="local" />); + renderWithI18n( {}} onAdd={() => {}} initialSourceType="local" />); fireEvent.click(screen.getByRole("button", { name: /git url/i })); @@ -85,7 +91,7 @@ describe("AddProjectModal Accessibility", () => { }); test("new project setup omits the techstack detection option", () => { - render( {}} onAdd={() => {}} initialSourceType="new_project" />); + renderWithI18n( {}} onAdd={() => {}} initialSourceType="new_project" />); expect(screen.queryByRole("button", { name: /Techstack/i })).not.toBeInTheDocument(); expect(screen.queryByText(/Initialize with Project Setup Agent/i)).not.toBeInTheDocument(); diff --git a/dashboard/src/v2/i18n/messages/projects.ts b/dashboard/src/v2/i18n/messages/projects.ts new file mode 100644 index 0000000000..c7e25dbca4 --- /dev/null +++ b/dashboard/src/v2/i18n/messages/projects.ts @@ -0,0 +1,362 @@ +import { defineDashboardMessages } from "../locales.js"; + +export const projectMessages = defineDashboardMessages({ + en: { + projects: "Projects", + sourceRepositories: "Source repositories", + manageProjects: "Manage Projects", + projectsSubtitle: "Connect repositories and local directories, choose the active workspace, and keep project setup close at hand.", + runningCount: { one: "{count} Running", other: "{count} Running" }, + totalCount: { one: "{count} Total", other: "{count} Total" }, + newProject: "New Project", + filterProjects: "Filter projects by status", + filterAll: "All", + filterRunning: "Running", + filterIdle: "Idle", + filterFailed: "Failed", + projectCards: "Project cards", + loadingProjects: "Loading projects.", + projectsLoadFailed: "Projects could not be loaded", + addProject: "Add Project", + noProjects: "No projects connected", + noProjectsDescription: "No projects connected. Add a project to start tracking work.", + noRunningProjects: "No running projects", + noIdleProjects: "No idle projects", + noFailedProjects: "No failed projects", + filteredEmptyDescription: "Choose another filter to see the rest of your projects.", + showAllProjects: "Show all projects", + setupAgent: "Project Setup Agent", + setupProjectTitle: "Setup {name}", + setupDescription: "Choose which repository artifacts the setup agent should prepare.", + closeProjectSetup: "Close project setup", + close: "Close", + cancel: "Cancel", + setupProject: "Setup project", + runSetupProject: "Setup Project", + settingUp: "Setting up...", + setupAgents: "Agents", + setupAgentsDescription: "Specialists and routing.", + setupQuicksprints: "Quicksprints", + setupQuicksprintsDescription: "Sprint templates.", + setupPreviewScript: "Preview Script", + setupPreviewScriptDescription: "Container startup.", + setupCi: "CI", + setupCiDescription: "Basic checks.", + setupTechstack: "Techstack", + setupTechstackDescription: "Detect and assign from manifests.", + setupDocs: "Docs", + setupDocsDescription: "Embed repository docs into Knowledge docs.", + setupStarting: "Starting project initialization for {name}. The invocation rail will open as soon as tracking is ready.", + setupRunning: "Project initialization is running for {name}. Invocation {invocation} is available now.", + setupFinished: "Project initialization finished for {name}. Review the invocation output for generated artifacts.", + setupFailed: "Project initialization failed for {name}: {message}", + setupInvocationFailed: "Project initialization invocation failed.", + openInvocation: "Open invocation", + openSetupInvocation: "Open setup invocation", + confirmDeleteTitle: "Delete {name}?", + confirmDeleteDescription: "This removes the project and its local runtime data from Code UX. The repository checkout is not deleted.", + confirmDelete: "Delete project", + deletingProject: "Deleting project...", + deleteFailed: "Project deletion failed: {message}", + statusRunning: "Running", + statusFailed: "Failed", + statusNeedsReview: "Needs review", + statusIdle: "Idle", + statusLabel: "Status: {status}", + projectArticle: "Project: {name}", + selectedProject: "Selected project: {name}", + selectProject: "Select project: {name}", + projectIsSelected: "{name} is selected", + selected: "Selected", + path: "Path", + repository: "Repository", + branch: "Branch", + lastRun: "Last run", + notSet: "Not set", + noRunsYet: "No runs yet", + projectSetupRunning: "Project setup running", + setupInvocationStarting: "Project setup invocation is starting", + setupAlreadyRunning: "Project setup is already running", + sprints: "Sprints", + open: "Open", + done: "Done", + completion: "Completion", + taskCompletion: "{name} task completion", + selectProjectAction: "Select project", + selectNamedProject: "Select {name}", + projectSettings: "Project settings", + deleteProject: "Delete project", + remoteGit: "Remote Git", + localRepository: "Local repo", + local: "Local", + localProjectDescription: "Local project rooted at {path}.", + localRepositoryDescription: "Local project with inferred {provider} origin on {host}.", + remoteRepositoryDescription: "{provider} repository hosted on {host}.", + openProject: "Open project", + openAction: "Open", + settings: "Settings", + delete: "Delete", + connectProjectDescription: "Connect a local folder or Git repository", + createWebApp: "Create Web App.", + createDesktopApp: "Create Desktop App.", + webApp: "Web App", + desktopApp: "Desktop App", + initializeNewAppDescription: "Initialize a new {kind} repository with explicit project techstack settings", + connectRepositoryDescription: "Connect a local directory or remote repository", + source: "Source", + sourceType: "Source Type", + localProject: "Local Project", + gitUrl: "Git URL", + gitRepo: "Git Repo", + localRepo: "Local Repo", + remoteRepo: "Remote Repo", + sourceSelected: "{source} selected", + contextSelected: "{context} context selected.", + setupOptionsStep: "Setup Options step.", + closeDialog: "Close dialog", + projectName: "Project Name", + required: "required", + optional: "optional", + projectNamePlaceholder: "My Awesome Project", + projectNameRequired: "Project Name is required.", + repositoryUrl: "Repository URL", + repositoryUrlRequired: "Repository URL is required.", + gitUrlSlug: "Git URL Slug", + gitUrlSlugRequired: "Git URL Slug is required.", + directoryPath: "Directory Path", + directoryPathRequired: "Directory Path is required.", + cloneIntoDirectory: "Clone Into Directory", + cloneInto: "Clone Into", + browse: "Browse", + browseDirectories: "Browse directories", + browseCloneDirectory: "Browse clone directory", + reviewRequiredFields: "Review required fields: {errors}", + retry: "Retry", + selectedDirectory: "Selected directory: {path}", + loadingPath: "Loading {path}", + loadingDirectories: "Loading directories", + directoryLoadFailed: "Directory load failed: {message}", + childDirectories: { one: "{count} child directory in {path}.", other: "{count} child directories in {path}." }, + directoryPickerReady: "Directory picker ready.", + goToParentDirectory: "Go to parent directory", + goUp: "Go up", + goToHomeDirectory: "Go to home directory", + home: "Home", + refreshDirectories: "Refresh directories", + refresh: "Refresh", + currentPath: "Current path {path}", + directoryPickerPath: "Directory picker path", + use: "Use", + loading: "Loading", + noChildDirectories: "No child directories in {path}", + setupAgentsLongDescription: "Specialist agents and orchestrator routing.", + setupQuicksprintsLongDescription: "Repository-specific sprint templates.", + setupPreviewScriptLongDescription: "Container startup script for browser previews.", + setupCiLongDescription: "Basic GitHub/GitLab error-checking pipelines.", + setupTechstackLongDescription: "Detect and assign a project stack from manifests.", + initializeWithSetup: "Initialize with Project Setup Agent", + initializeWithSetupDescription: "Research the codebase after creation and generate project-specific agents, routing, quicksprints, preview startup, basic CI, and a detected techstack.", + setupScope: "Setup Scope", + chooseProjectAssets: "Choose project assets", + all: "All", + previewScriptHelp: "This is only needed when the container struggles to start with the default script", + initMode: "Init Mode", + provider: "Provider", + providerDetectionHint: "Provider detection is optional here; both buttons are shown.", + visibility: "Visibility", + private: "Private", + public: "Public", + continue: "Continue", + adding: "Adding...", + projectCreationInProgress: "Project creation is in progress.", + projectCreationDisabledReason: "Project creation is in progress. Wait for it to finish or retry if it fails.", + repoName: "Repo Name", + repositoryNameRequired: "Repository Name is required.", + initialize: "Initialize", + mode: "Mode", + newProjectTitle: "New Project.", + initializeRepositoryDescription: "Initialize a git repo locally or on a remote provider", + noGitProviders: "No git providers configured. Add a GitHub or GitLab token in Settings.", + initializing: "Initializing...", + createProject: "Create Project", + }, + de: { + projects: "Projekte", + sourceRepositories: "Quell-Repositorys", + manageProjects: "Projekte verwalten", + projectsSubtitle: "Repositorys und lokale Verzeichnisse verbinden, den aktiven Arbeitsbereich auswählen und die Projekteinrichtung direkt erreichen.", + runningCount: { one: "{count} aktiv", other: "{count} aktiv" }, + totalCount: { one: "{count} insgesamt", other: "{count} insgesamt" }, + newProject: "Neues Projekt", + filterProjects: "Projekte nach Status filtern", + filterAll: "Alle", + filterRunning: "Aktiv", + filterIdle: "Inaktiv", + filterFailed: "Fehlgeschlagen", + projectCards: "Projektkarten", + loadingProjects: "Projekte werden geladen.", + projectsLoadFailed: "Projekte konnten nicht geladen werden", + addProject: "Projekt hinzufügen", + noProjects: "Keine Projekte verbunden", + noProjectsDescription: "Keine Projekte verbunden. Füge ein Projekt hinzu, um Arbeit zu verfolgen.", + noRunningProjects: "Keine aktiven Projekte", + noIdleProjects: "Keine inaktiven Projekte", + noFailedProjects: "Keine fehlgeschlagenen Projekte", + filteredEmptyDescription: "Wähle einen anderen Filter, um die übrigen Projekte anzuzeigen.", + showAllProjects: "Alle Projekte anzeigen", + setupAgent: "Projekteinrichtungs-Agent", + setupProjectTitle: "{name} einrichten", + setupDescription: "Wähle aus, welche Repository-Artefakte der Einrichtungs-Agent vorbereiten soll.", + closeProjectSetup: "Projekteinrichtung schließen", + close: "Schließen", + cancel: "Abbrechen", + setupProject: "Projekt einrichten", + runSetupProject: "Projekt einrichten", + settingUp: "Wird eingerichtet...", + setupAgents: "Agenten", + setupAgentsDescription: "Spezialisten und Routing.", + setupQuicksprints: "Quicksprints", + setupQuicksprintsDescription: "Sprint-Vorlagen.", + setupPreviewScript: "Vorschau-Skript", + setupPreviewScriptDescription: "Container-Start.", + setupCi: "CI", + setupCiDescription: "Grundlegende Prüfungen.", + setupTechstack: "Techstack", + setupTechstackDescription: "Aus Manifesten erkennen und zuweisen.", + setupDocs: "Dokumentation", + setupDocsDescription: "Repository-Dokumentation in die Wissensbibliothek einbetten.", + setupStarting: "Die Projektinitialisierung für {name} wird gestartet. Die Aufrufleiste öffnet sich, sobald die Verfolgung bereit ist.", + setupRunning: "Die Projektinitialisierung für {name} läuft. Aufruf {invocation} ist jetzt verfügbar.", + setupFinished: "Die Projektinitialisierung für {name} ist abgeschlossen. Prüfe die Aufrufausgabe auf erzeugte Artefakte.", + setupFailed: "Die Projektinitialisierung für {name} ist fehlgeschlagen: {message}", + setupInvocationFailed: "Der Aufruf zur Projektinitialisierung ist fehlgeschlagen.", + openInvocation: "Aufruf öffnen", + openSetupInvocation: "Einrichtungsaufruf öffnen", + confirmDeleteTitle: "{name} löschen?", + confirmDeleteDescription: "Dadurch werden das Projekt und seine lokalen Laufzeitdaten aus Code UX entfernt. Das Repository-Verzeichnis wird nicht gelöscht.", + confirmDelete: "Projekt löschen", + deletingProject: "Projekt wird gelöscht...", + deleteFailed: "Projekt konnte nicht gelöscht werden: {message}", + statusRunning: "Aktiv", + statusFailed: "Fehlgeschlagen", + statusNeedsReview: "Prüfung erforderlich", + statusIdle: "Inaktiv", + statusLabel: "Status: {status}", + projectArticle: "Projekt: {name}", + selectedProject: "Ausgewähltes Projekt: {name}", + selectProject: "Projekt auswählen: {name}", + projectIsSelected: "{name} ist ausgewählt", + selected: "Ausgewählt", + path: "Pfad", + repository: "Repository", + branch: "Branch", + lastRun: "Letzter Lauf", + notSet: "Nicht festgelegt", + noRunsYet: "Noch keine Läufe", + projectSetupRunning: "Projekteinrichtung läuft", + setupInvocationStarting: "Der Aufruf zur Projekteinrichtung wird gestartet", + setupAlreadyRunning: "Die Projekteinrichtung läuft bereits", + sprints: "Sprints", + open: "Offen", + done: "Erledigt", + completion: "Fortschritt", + taskCompletion: "Aufgabenfortschritt für {name}", + selectProjectAction: "Projekt auswählen", + selectNamedProject: "{name} auswählen", + projectSettings: "Projekteinstellungen", + deleteProject: "Projekt löschen", + remoteGit: "Remote-Git", + localRepository: "Lokales Repository", + local: "Lokal", + localProjectDescription: "Lokales Projekt im Verzeichnis {path}.", + localRepositoryDescription: "Lokales Projekt mit erkanntem {provider}-Ursprung auf {host}.", + remoteRepositoryDescription: "{provider}-Repository auf {host}.", + openProject: "Projekt öffnen", + openAction: "Öffnen", + settings: "Einstellungen", + delete: "Löschen", + connectProjectDescription: "Einen lokalen Ordner oder ein Git-Repository verbinden", + createWebApp: "Web-App erstellen.", + createDesktopApp: "Desktop-App erstellen.", + webApp: "Web-App", + desktopApp: "Desktop-App", + initializeNewAppDescription: "Ein neues {kind}-Repository mit expliziten Projekt-Techstack-Einstellungen initialisieren", + connectRepositoryDescription: "Ein lokales Verzeichnis oder Remote-Repository verbinden", + source: "Quelle", + sourceType: "Quelltyp", + localProject: "Lokales Projekt", + gitUrl: "Git-URL", + gitRepo: "Git-Repository", + localRepo: "Lokales Repository", + remoteRepo: "Remote-Repository", + sourceSelected: "{source} ausgewählt", + contextSelected: "Kontext {context} ausgewählt.", + setupOptionsStep: "Schritt Einrichtungsoptionen.", + closeDialog: "Dialog schließen", + projectName: "Projektname", + required: "erforderlich", + optional: "optional", + projectNamePlaceholder: "Mein großartiges Projekt", + projectNameRequired: "Projektname ist erforderlich.", + repositoryUrl: "Repository-URL", + repositoryUrlRequired: "Repository-URL ist erforderlich.", + gitUrlSlug: "Git-URL-Kürzel", + gitUrlSlugRequired: "Git-URL-Kürzel ist erforderlich.", + directoryPath: "Verzeichnispfad", + directoryPathRequired: "Verzeichnispfad ist erforderlich.", + cloneIntoDirectory: "In Verzeichnis klonen", + cloneInto: "Klonen nach", + browse: "Durchsuchen", + browseDirectories: "Verzeichnisse durchsuchen", + browseCloneDirectory: "Klonverzeichnis durchsuchen", + reviewRequiredFields: "Erforderliche Felder prüfen: {errors}", + retry: "Erneut versuchen", + selectedDirectory: "Ausgewähltes Verzeichnis: {path}", + loadingPath: "{path} wird geladen", + loadingDirectories: "Verzeichnisse werden geladen", + directoryLoadFailed: "Verzeichnis konnte nicht geladen werden: {message}", + childDirectories: { one: "{count} Unterverzeichnis in {path}.", other: "{count} Unterverzeichnisse in {path}." }, + directoryPickerReady: "Verzeichnisauswahl bereit.", + goToParentDirectory: "Zum übergeordneten Verzeichnis wechseln", + goUp: "Nach oben", + goToHomeDirectory: "Zum Home-Verzeichnis wechseln", + home: "Home", + refreshDirectories: "Verzeichnisse aktualisieren", + refresh: "Aktualisieren", + currentPath: "Aktueller Pfad {path}", + directoryPickerPath: "Pfad der Verzeichnisauswahl", + use: "Übernehmen", + loading: "Wird geladen", + noChildDirectories: "Keine Unterverzeichnisse in {path}", + setupAgentsLongDescription: "Spezialisierte Agenten und Orchestrator-Routing.", + setupQuicksprintsLongDescription: "Repository-spezifische Sprint-Vorlagen.", + setupPreviewScriptLongDescription: "Container-Startskript für Browser-Vorschauen.", + setupCiLongDescription: "Grundlegende GitHub/GitLab-Pipelines zur Fehlerprüfung.", + setupTechstackLongDescription: "Einen Projekt-Techstack aus Manifesten erkennen und zuweisen.", + initializeWithSetup: "Mit Projekteinrichtungs-Agent initialisieren", + initializeWithSetupDescription: "Die Codebasis nach der Erstellung untersuchen und projektspezifische Agenten, Routing, Quicksprints, Vorschau-Start, grundlegende CI und einen erkannten Techstack erzeugen.", + setupScope: "Einrichtungsumfang", + chooseProjectAssets: "Projektartefakte auswählen", + all: "Alle", + previewScriptHelp: "Dies ist nur nötig, wenn der Container mit dem Standardskript nicht startet", + initMode: "Initialisierungsmodus", + provider: "Anbieter", + providerDetectionHint: "Die Anbietererkennung ist hier optional; beide Schaltflächen werden angezeigt.", + visibility: "Sichtbarkeit", + private: "Privat", + public: "Öffentlich", + continue: "Weiter", + adding: "Wird hinzugefügt...", + projectCreationInProgress: "Die Projekterstellung läuft.", + projectCreationDisabledReason: "Die Projekterstellung läuft. Warte auf den Abschluss oder versuche es nach einem Fehler erneut.", + repoName: "Repository-Name", + repositoryNameRequired: "Repository-Name ist erforderlich.", + initialize: "Initialisieren", + mode: "Modus", + newProjectTitle: "Neues Projekt.", + initializeRepositoryDescription: "Ein Git-Repository lokal oder bei einem Remote-Anbieter initialisieren", + noGitProviders: "Keine Git-Anbieter konfiguriert. Füge in den Einstellungen ein GitHub- oder GitLab-Token hinzu.", + initializing: "Wird initialisiert...", + createProject: "Projekt erstellen", + }, +}); diff --git a/dashboard/src/v2/lib/project-card-view-model.ts b/dashboard/src/v2/lib/project-card-view-model.ts index 2b8bdaf32a..ddc11ac58e 100644 --- a/dashboard/src/v2/lib/project-card-view-model.ts +++ b/dashboard/src/v2/lib/project-card-view-model.ts @@ -6,10 +6,17 @@ import type { ProjectCardViewModel, Source, } from "../types.js"; +import { + translateDashboardMessage, + type DashboardLocale, + type DashboardMessageVariables, + type DashboardTextMessageKey, +} from "../i18n/locales.js"; +import { projectMessages } from "../i18n/messages/projects.js"; export const PROJECT_CARD_EMPTY_VALUE = "--"; -const PROJECT_CARD_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat("en-US", { +const createProjectCardTimestampFormatter = (locale: DashboardLocale): Intl.DateTimeFormat => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", @@ -24,36 +31,11 @@ const PROJECT_PROVIDER_LABELS: Record = { local: "Local", }; -const PROJECT_CARD_ACTIONS: Array> = [ - { - kind: "open-project", - label: "Open", - ariaLabel: "Open project", - title: "Open project", - tone: "default", - }, - { - kind: "setup-project", - label: "Setup project", - ariaLabel: "Setup project", - title: "Setup project", - tone: "default", - }, - { - kind: "settings", - label: "Settings", - ariaLabel: "Project settings", - title: "Project settings", - tone: "default", - }, - { - kind: "delete", - label: "Delete", - ariaLabel: "Delete project", - title: "Delete project", - tone: "danger", - }, -]; +const projectText = ( + locale: DashboardLocale, + key: DashboardTextMessageKey, + variables?: DashboardMessageVariables, +): string => translateDashboardMessage(projectMessages, locale, key, variables); export function formatProjectCardDisplayValue(value: string | null | undefined): ProjectCardDisplayValue { const trimmed = typeof value === "string" ? value.trim() : ""; @@ -63,7 +45,10 @@ export function formatProjectCardDisplayValue(value: string | null | undefined): }; } -export function formatProjectCardTimestamp(value: string | null | undefined): ProjectCardDisplayValue { +export function formatProjectCardTimestamp( + value: string | null | undefined, + locale: DashboardLocale = "en", +): ProjectCardDisplayValue { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) { return { @@ -81,7 +66,7 @@ export function formatProjectCardTimestamp(value: string | null | undefined): Pr } return { - value: PROJECT_CARD_TIMESTAMP_FORMATTER.format(parsed), + value: createProjectCardTimestampFormatter(locale).format(parsed), isEmpty: false, }; } @@ -126,31 +111,37 @@ export function getProjectCardLastRunStatus(project: Source): ProjectCardDisplay return formatProjectCardDisplayValue(project.lastRunStatus); } -export function getProjectCardSourceBadge(project: Source): ProjectCardSourceBadge { +export function getProjectCardSourceBadge( + project: Source, + locale: DashboardLocale = "en", +): ProjectCardSourceBadge { if (project.sourceType === "git") { return { kind: "remote-git", - label: "Remote Git", - description: buildSourceDescription("remote-git", project), + label: projectText(locale, "remoteGit"), + description: buildSourceDescription("remote-git", project, locale), }; } if (project.repoUrl?.trim()) { return { kind: "local-repository", - label: "Local repo", - description: buildSourceDescription("local-repository", project), + label: projectText(locale, "localRepository"), + description: buildSourceDescription("local-repository", project, locale), }; } return { kind: "local", - label: "Local", - description: buildSourceDescription("local", project), + label: projectText(locale, "local"), + description: buildSourceDescription("local", project, locale), }; } -export function getProjectCardTaskCompletion(project: Source): ProjectCardTaskCompletion { +export function getProjectCardTaskCompletion( + project: Source, + locale: DashboardLocale = "en", +): ProjectCardTaskCompletion { const completedTasks = Math.max(0, Math.trunc(project.completedTasks)); const openTasks = Math.max(0, Math.trunc(project.openTasks)); const totalTasks = completedTasks + openTasks; @@ -167,7 +158,10 @@ export function getProjectCardTaskCompletion(project: Source): ProjectCardTaskCo const percentage = Math.round((completedTasks / totalTasks) * 100); return { - value: `${percentage}%`, + value: new Intl.NumberFormat(locale, { + style: "percent", + maximumFractionDigits: 0, + }).format(percentage / 100), percentage, completedTasks, openTasks, @@ -176,41 +170,83 @@ export function getProjectCardTaskCompletion(project: Source): ProjectCardTaskCo }; } -export function buildProjectCardActions(): ProjectCardActionDescriptor[] { - return PROJECT_CARD_ACTIONS.map((action) => ({ ...action })); +export function buildProjectCardActions(locale: DashboardLocale = "en"): ProjectCardActionDescriptor[] { + return [ + { + kind: "open-project", + label: projectText(locale, "openAction"), + ariaLabel: projectText(locale, "openProject"), + title: projectText(locale, "openProject"), + tone: "default", + }, + { + kind: "setup-project", + label: projectText(locale, "setupProject"), + ariaLabel: projectText(locale, "setupProject"), + title: projectText(locale, "setupProject"), + tone: "default", + }, + { + kind: "settings", + label: projectText(locale, "settings"), + ariaLabel: projectText(locale, "projectSettings"), + title: projectText(locale, "projectSettings"), + tone: "default", + }, + { + kind: "delete", + label: projectText(locale, "delete"), + ariaLabel: projectText(locale, "deleteProject"), + title: projectText(locale, "deleteProject"), + tone: "danger", + }, + ]; } -export function buildProjectCardViewModel(project: Source): ProjectCardViewModel { +export function buildProjectCardViewModel( + project: Source, + locale: DashboardLocale = "en", +): ProjectCardViewModel { return { - sourceBadge: getProjectCardSourceBadge(project), - sourceTypeLabel: project.sourceType === "git" ? "Remote Git" : project.repoUrl?.trim() ? "Local repo" : "Local", + sourceBadge: getProjectCardSourceBadge(project, locale), + sourceTypeLabel: project.sourceType === "git" + ? projectText(locale, "remoteGit") + : project.repoUrl?.trim() + ? projectText(locale, "localRepository") + : projectText(locale, "local"), providerLabel: getProjectCardProviderLabel(project), hostLabel: getProjectCardHostLabel(project), gitUrl: getProjectCardGitUrl(project), localDirectory: getProjectCardLocalDirectory(project), - createdAt: formatProjectCardTimestamp(project.createdAt), - updatedAt: formatProjectCardTimestamp(project.updatedAt), - lastRunAt: formatProjectCardTimestamp(project.lastRunAt), + createdAt: formatProjectCardTimestamp(project.createdAt, locale), + updatedAt: formatProjectCardTimestamp(project.updatedAt, locale), + lastRunAt: formatProjectCardTimestamp(project.lastRunAt, locale), lastRunStatus: getProjectCardLastRunStatus(project), branch: getProjectCardBranch(project), featureBranchPrefix: getProjectCardFeatureBranchPrefix(project), - taskCompletion: getProjectCardTaskCompletion(project), + taskCompletion: getProjectCardTaskCompletion(project, locale), emptyValue: PROJECT_CARD_EMPTY_VALUE, - actions: buildProjectCardActions(), + actions: buildProjectCardActions(locale), }; } -function buildSourceDescription(kind: ProjectCardSourceBadge["kind"], project: Source): string { +function buildSourceDescription( + kind: ProjectCardSourceBadge["kind"], + project: Source, + locale: DashboardLocale, +): string { const provider = getProjectCardProviderLabel(project).value; const host = project.gitHostDomain?.trim() || PROJECT_CARD_EMPTY_VALUE; if (kind === "local") { - return `Local project rooted at ${project.baseDir || PROJECT_CARD_EMPTY_VALUE}.`; + return projectText(locale, "localProjectDescription", { + path: project.baseDir || PROJECT_CARD_EMPTY_VALUE, + }); } if (kind === "local-repository") { - return `Local project with inferred ${provider} origin on ${host}.`; + return projectText(locale, "localRepositoryDescription", { provider, host }); } - return `${provider} repository hosted on ${host}.`; + return projectText(locale, "remoteRepositoryDescription", { provider, host }); } diff --git a/dashboard/src/v2/lib/projects-page-view-model.ts b/dashboard/src/v2/lib/projects-page-view-model.ts index 3f2f9606c1..bf9c7cae40 100644 --- a/dashboard/src/v2/lib/projects-page-view-model.ts +++ b/dashboard/src/v2/lib/projects-page-view-model.ts @@ -5,13 +5,15 @@ export type ProjectFilter = "All" | "Running" | "Idle" | "Failed"; export interface ProjectFilterDefinition { filter: ProjectFilter; status: SourceStatus | null; + labelKey: "filterAll" | "filterRunning" | "filterIdle" | "filterFailed"; + emptyMessageKey: "noRunningProjects" | "noIdleProjects" | "noFailedProjects" | null; } export const PROJECT_FILTER_DEFINITIONS: readonly ProjectFilterDefinition[] = [ - { filter: "All", status: null }, - { filter: "Running", status: "running" }, - { filter: "Idle", status: "idle" }, - { filter: "Failed", status: "failed" }, + { filter: "All", status: null, labelKey: "filterAll", emptyMessageKey: null }, + { filter: "Running", status: "running", labelKey: "filterRunning", emptyMessageKey: "noRunningProjects" }, + { filter: "Idle", status: "idle", labelKey: "filterIdle", emptyMessageKey: "noIdleProjects" }, + { filter: "Failed", status: "failed", labelKey: "filterFailed", emptyMessageKey: "noFailedProjects" }, ]; export interface ProjectsPageViewModel { diff --git a/docs-web/user/dashboard/projects.md b/docs-web/user/dashboard/projects.md index 91cec3f179..47385cb3ef 100644 --- a/docs-web/user/dashboard/projects.md +++ b/docs-web/user/dashboard/projects.md @@ -43,6 +43,8 @@ The primary card surface and footer selection button expose whether the project ## Creating a project +The Projects page and shared creation dialog follow the dashboard language setting. English and German labels cover local and Git imports, new local and remote repositories, directory browsing, validation, setup scope, and progress feedback. Changing the language does not alter submitted source types, initialization modes, setup choices, names, paths, repository identifiers, providers, or project settings. + Click the dashed **Add Project** card to open the shared modal in local-import mode, then choose **Local Project**, **Git URL**, or **New Project**. Imported local projects receive only a local git-mode project override, so Code UX operates against local Git state. Imported Git URL projects inherit the system remote-git defaults. Imported projects stay techstack-unassigned until you choose a project techstack in settings, use the top bar selector, or run Project Setup Agent techstack detection. Click **New Project** on the Projects page to initialize a new repository through the same modal. New project initialization does not scaffold application source files in the dashboard; it sends `new-local` or `new-remote` initialization data to the backend repository creation flow. @@ -90,7 +92,7 @@ Use **Select project** to make a project active, or use **Project settings** to ## Deleting a project -Deletion is destructive. The project card's **Delete project** action sends the existing dashboard deletion request immediately and refreshes the gallery, so verify the target before activating it. Project deletion removes the project and its associated local runtime data; it does not delete the repository checkout or files inside `/.code-ux/`. +Deletion is destructive. The project card's **Delete project** action opens a confirmation dialog that names the project and explains what remains on disk. Confirming sends the existing dashboard deletion request and refreshes the gallery. Project deletion removes the project and its associated local runtime data; it does not delete the repository checkout or files inside `/.code-ux/`. The Settings **Danger Zone** provides a confirmation dialog for its **Delete Project** workflow. Programmatic deletion through the MCP `manage_projects` action remains gated by explicit `approval.confirmed = true`. diff --git a/docs/dashboard/design-system-projects.md b/docs/dashboard/design-system-projects.md index 739d96f40d..2a579405b5 100644 --- a/docs/dashboard/design-system-projects.md +++ b/docs/dashboard/design-system-projects.md @@ -31,6 +31,7 @@ This document records the implemented low-noise gallery, responsive layout, and - A load failure uses an assertive alert with the returned error and an **Add Project** action. A collection with no projects and a filter with no matches use polite status surfaces; the no-match state also provides **Show all projects**. - Each project is a named article. Its primary selection surface and footer selection button are native buttons with stable `aria-pressed` state. The selected badge is a named status, task completion is a labelled progress bar, and status dots expose readable status text. - Every interactive control has a visible focus ring. Setup, settings, and delete buttons stop event propagation so keyboard or pointer activation cannot also select the card. +- Delete opens a localized confirmation dialog that names the project, explains that the checkout is retained, prevents duplicate confirmation while pending, and leaves a verbatim API diagnostic available for retry when deletion fails. - The shared Add Project modal traps focus, initially focuses the project name, restores focus when closed, labels required fields, and exposes source/setup choices as keyboard-focusable controls. Invalid submit announces one summary, marks affected fields, moves focus to the first invalid field, and scrolls the modal body to it. - Directory browsing politely announces loading, the current path, empty folders, and selection; failures use an alert. Pending submission marks the submit action busy, disables close/cancel/submit with a reason, and leaves retryable errors in the modal. @@ -43,7 +44,10 @@ This document records the implemented low-noise gallery, responsive layout, and ## Preserved workflows +- English and German use the same stable source types, initialization modes, setup option keys, settings overrides, and API payloads. Only dashboard-authored labels and announcements change; names, paths, repository identifiers, branches, providers, application-kind contract values, and runtime diagnostics remain verbatim. +- Project timestamps, counts, and percentages use the active locale. The feature-owned project catalog is also consumed by the shared Add Project and New Project modals, so callers on other routes inherit the active language without caller changes. + - **Add Project** opens the shared modal in local-import mode, from which users can choose Local Project, Git URL, or New Project. **New Project** opens the same modal with New Project selected; it sends the existing `new-local` or `new-remote` initialization contract and does not scaffold application source in the dashboard. - Selecting either card selection control persists the active project. **Project settings** first selects that project and then routes to `/config`. -- **Delete project** invokes the existing dashboard deletion request and refreshes the collection; it does not introduce a new deletion API or initialization contract. MCP deletion remains approval-gated. +- **Delete project** first opens the localized confirmation dialog, then invokes the existing dashboard deletion request and refreshes the collection; it does not introduce a new deletion API or initialization contract. MCP deletion remains approval-gated. - **Setup project** opens the existing setup-scope dialog for agents, quicksprints, preview script, CI, techstack detection, and opt-in docs embedding. Starting setup uses the existing background setup endpoint, reports progress through toasts and the card, polls the matching invocation, and exposes **Open invocation** as soon as its ID is available and again in completion feedback. diff --git a/docs/dashboard/internationalization.md b/docs/dashboard/internationalization.md index c0047eca70..2d3f0f1995 100644 --- a/docs/dashboard/internationalization.md +++ b/docs/dashboard/internationalization.md @@ -45,6 +45,14 @@ Interpolation replaces only named `{variable}` tokens through literal string sub `useDashboardI18n` exposes `formatNumber`, `formatDate`, `formatTime`, `formatRelativeTime`, and `formatList`. Each function is rebound when the active locale changes and accepts the corresponding native `Intl` options. New localized UI should use these functions instead of adding fixed `en-US` formatters. +## Project management coverage + +The Projects route owns `i18n/messages/projects.ts`. Its catalog covers the gallery, project cards, status filters, setup and deletion dialogs, notifications, directory browser, and both shared project-creation modals. Because `AddProjectModal` reads the root locale directly, the same translated form is used when it opens from the top navigation, Tasks, Sprints, or the dashboard assistant; those callers do not pass translated labels or alter their project payloads. + +Project card timestamps, counts, and completion percentages use locale-bound `Intl` formatting. Project names, local paths, repository URLs and slugs, branches, provider names, application-kind contract values, setup payloads, and API/provider diagnostics remain verbatim. The internal filter and creation-mode identifiers also remain stable English contract values while only their labels are localized. + +Project deletion uses a localized confirmation dialog before invoking the existing deletion request. Creation, setup, selection, Settings navigation, invocation tracking, duplicate-submit protection, and stale project-selection handling retain their existing contracts. + ## Translation scope The initial application bundle translates only root-owned shell copy: the skip link, main landmark label, route loading announcement, and hidden footer. Route catalogs should be imported with their route when those features are localized. diff --git a/tests/dashboard/lib/project-card-view-model.test.ts b/tests/dashboard/lib/project-card-view-model.test.ts index 0df83319ab..8d47664c5c 100644 --- a/tests/dashboard/lib/project-card-view-model.test.ts +++ b/tests/dashboard/lib/project-card-view-model.test.ts @@ -252,4 +252,35 @@ describe("project-card-view-model", () => { isEmpty: false, }); }); + + it("formats authored card copy, dates, and percentages for German without changing source values", () => { + const project = createProject({ + sourceType: "git", + sourceRef: "https://github.com/acme/widgets.git", + repoUrl: "https://github.com/acme/widgets.git", + gitProvider: "github", + gitHostDomain: "github.com", + completedTasks: 3, + openTasks: 1, + lastRunAt: "2026-01-04T05:06:07.000Z", + }); + + const viewModel = buildProjectCardViewModel(project, "de"); + + expect(viewModel.sourceBadge).toEqual({ + kind: "remote-git", + label: "Remote-Git", + description: "GitHub-Repository auf github.com.", + }); + expect(viewModel.gitUrl.value).toBe("https://github.com/acme/widgets.git"); + expect(viewModel.providerLabel.value).toBe("GitHub"); + expect(viewModel.lastRunAt.value).toBe("4. Jan. 2026, 5:06"); + expect(viewModel.taskCompletion.value).toBe("75 %"); + expect(buildProjectCardActions("de").map((action) => action.label)).toEqual([ + "Öffnen", + "Projekt einrichten", + "Einstellungen", + "Löschen", + ]); + }); }); diff --git a/tests/dashboard/lib/projects-page-view-model.test.ts b/tests/dashboard/lib/projects-page-view-model.test.ts index 7ef5acc37a..be7241005b 100644 --- a/tests/dashboard/lib/projects-page-view-model.test.ts +++ b/tests/dashboard/lib/projects-page-view-model.test.ts @@ -36,10 +36,10 @@ function createSource(id: string, status: SourceStatus): Source { describe("projects-page-view-model", () => { it("defines filters in display order with their matching statuses", () => { expect(PROJECT_FILTER_DEFINITIONS).toEqual([ - { filter: "All", status: null }, - { filter: "Running", status: "running" }, - { filter: "Idle", status: "idle" }, - { filter: "Failed", status: "failed" }, + { filter: "All", status: null, labelKey: "filterAll", emptyMessageKey: null }, + { filter: "Running", status: "running", labelKey: "filterRunning", emptyMessageKey: "noRunningProjects" }, + { filter: "Idle", status: "idle", labelKey: "filterIdle", emptyMessageKey: "noIdleProjects" }, + { filter: "Failed", status: "failed", labelKey: "filterFailed", emptyMessageKey: "noFailedProjects" }, ]); }); diff --git a/tests/dashboard/v2/add-project-modal.test.tsx b/tests/dashboard/v2/add-project-modal.test.tsx index 82a2212713..f7647b3dbd 100644 --- a/tests/dashboard/v2/add-project-modal.test.tsx +++ b/tests/dashboard/v2/add-project-modal.test.tsx @@ -1,14 +1,21 @@ /** @vitest-environment happy-dom */ /** @jsx h */ import { h } from "preact"; -import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/preact"; +import { render as testingRender, screen, fireEvent, cleanup, waitFor } from "@testing-library/preact"; +import type { ComponentChildren } from "preact"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import * as matchers from "@testing-library/jest-dom/matchers"; import { AddProjectModal } from "../../../dashboard/src/v2/components/ui/AddProjectModal.js"; import { fetchLocalDirectories } from "../../../dashboard/src/v2/lib/project-api.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/context.js"; +import type { DashboardLocale } from "../../../dashboard/src/v2/i18n/locales.js"; expect.extend(matchers); +const render = (children: ComponentChildren, locale: DashboardLocale = "en") => testingRender( + {children}, +); + vi.mock("gsap", () => ({ default: { fromTo: vi.fn(), @@ -282,6 +289,85 @@ describe("AddProjectModal", () => { }); }); + it("submits unchanged local and Git import payloads from the German modal", async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + const first = render(, "de"); + + fireEvent.input(screen.getByLabelText(/Projektname/i), { target: { value: "Lokales Projekt" } }); + fireEvent.input(screen.getByLabelText(/Verzeichnispfad/i), { target: { value: "/workspace/lokal" } }); + fireEvent.click(screen.getByText("Mit Projekteinrichtungs-Agent initialisieren").closest("label")!); + fireEvent.submit(screen.getByLabelText(/Projektname/i).closest("form")!); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith({ + name: "Lokales Projekt", + type: "local", + path: "/workspace/lokal", + setup: { + enabled: false, + options: { + agents: true, + quicksprints: true, + previewScript: false, + ci: true, + techstack: true, + docs: false, + }, + }, + })); + + first.unmount(); + onAdd.mockClear(); + render(, "de"); + fireEvent.click(screen.getByRole("button", { name: "Git-URL" })); + fireEvent.input(screen.getByLabelText(/Projektname/i), { target: { value: "Remote Projekt" } }); + fireEvent.input(screen.getByLabelText(/Repository-URL/i), { target: { value: "https://example.com/team/repo.git" } }); + fireEvent.input(screen.getByLabelText(/In Verzeichnis klonen/i), { target: { value: "/workspace/clones" } }); + fireEvent.click(screen.getByText("Mit Projekteinrichtungs-Agent initialisieren").closest("label")!); + fireEvent.submit(screen.getByLabelText(/Projektname/i).closest("form")!); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith({ + name: "Remote Projekt", + type: "git", + path: "https://example.com/team/repo.git", + cloneDir: "/workspace/clones", + setup: { + enabled: false, + options: { + agents: true, + quicksprints: true, + previewScript: false, + ci: true, + techstack: true, + docs: false, + }, + }, + })); + }); + + it("announces German validation without translating entered values or API failures", async () => { + const onAdd = vi.fn().mockRejectedValue(new Error("provider diagnostic 42")); + render(, "de"); + fireEvent.click(screen.getByRole("button", { name: "Git-URL" })); + fireEvent.submit(screen.getByRole("dialog").querySelector("form")!); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent("Erforderliche Felder prüfen"); + expect(screen.getByText("Projektname ist erforderlich.")).toBeInTheDocument(); + expect(screen.getByText("Repository-URL ist erforderlich.")).toBeInTheDocument(); + }); + + fireEvent.input(screen.getByLabelText(/Projektname/i), { target: { value: "Unverändert" } }); + fireEvent.input(screen.getByLabelText(/Repository-URL/i), { target: { value: "ssh://host/Unverändert.git" } }); + fireEvent.click(screen.getByText("Mit Projekteinrichtungs-Agent initialisieren").closest("label")!); + fireEvent.submit(screen.getByLabelText(/Projektname/i).closest("form")!); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("provider diagnostic 42")); + expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ + name: "Unverändert", + path: "ssh://host/Unverändert.git", + })); + }); + it("browses into a directory and applies it to the local path input", async () => { vi.mocked(fetchLocalDirectories) .mockResolvedValueOnce({ diff --git a/tests/dashboard/v2/projects-page.test.tsx b/tests/dashboard/v2/projects-page.test.tsx index b15e1d80db..3cf497b01e 100644 --- a/tests/dashboard/v2/projects-page.test.tsx +++ b/tests/dashboard/v2/projects-page.test.tsx @@ -1,21 +1,29 @@ /** @vitest-environment happy-dom */ /** @jsx h */ -import { h } from "preact"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; +import { h, type ComponentChildren } from "preact"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, fireEvent, render as testingRender, screen, waitFor, within } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; import userEvent from "@testing-library/user-event"; import { ProjectsPage } from "../../../dashboard/src/v2/ProjectsPage.js"; import { useProjectData } from "../../../dashboard/src/v2/context/project-data.js"; import { useToast } from "../../../dashboard/src/v2/components/feedback/ToastProvider.js"; import { startProjectSetup } from "../../../dashboard/src/v2/lib/project-api.js"; +import { fetchProjectInvocations } from "../../../dashboard/src/v2/lib/invocation-api.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/context.js"; +import type { DashboardLocale } from "../../../dashboard/src/v2/i18n/locales.js"; expect.extend(matchers); +const render = (children: ComponentChildren, locale: DashboardLocale = "en") => testingRender( + {children}, +); + const navigateMock = vi.fn(); const selectProjectMock = vi.fn(() => Promise.resolve()); const deleteProjectMock = vi.fn(() => Promise.resolve()); const createProjectMock = vi.fn(() => Promise.resolve({})); +const addToastMock = vi.fn(); vi.mock("gsap", () => ({ default: { @@ -138,6 +146,10 @@ const createProject = (overrides: Record = {}) => ({ }); describe("ProjectsPage", () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { cleanup(); window.history.replaceState({}, "", "/projects"); @@ -149,7 +161,7 @@ describe("ProjectsPage", () => { invocationId: "invocation-1", agentId: "agent-1", }); - vi.mocked(useToast).mockReturnValue({ addToast: vi.fn() } as any); + vi.mocked(useToast).mockReturnValue({ addToast: addToastMock } as any); vi.mocked(useProjectData).mockReturnValue({ projects: [createProject()], selectedProjectId: "project-1", @@ -244,7 +256,10 @@ describe("ProjectsPage", () => { expect(navigateMock).toHaveBeenCalledWith({ to: "/config" }); fireEvent.click(screen.getByRole("button", { name: "Delete project" })); - expect(deleteProjectMock).toHaveBeenCalledOnce(); + const deleteDialog = screen.getByRole("dialog", { name: "Delete Widget Service?" }); + expect(deleteProjectMock).not.toHaveBeenCalled(); + fireEvent.click(within(deleteDialog).getByRole("button", { name: "Delete project" })); + await waitFor(() => expect(deleteProjectMock).toHaveBeenCalledOnce()); expect(selectProjectMock).toHaveBeenCalledTimes(2); }); @@ -546,4 +561,56 @@ describe("ProjectsPage", () => { }), })); }); + + it("keeps German project selection and deletion confirmation operable", async () => { + render(, "de"); + + expect(screen.getByRole("heading", { name: "Projekte verwalten" })).toBeInTheDocument(); + expect(screen.getByText("https://github.com/acme/widget-service.git")).toBeInTheDocument(); + expect(screen.getByText("4. Jan. 2026, 5:06")).toBeInTheDocument(); + expect(screen.getByText("completed")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Ausgewähltes Projekt: Widget Service" })); + expect(selectProjectMock).toHaveBeenCalledWith("project-1"); + + fireEvent.click(screen.getByRole("button", { name: "Projekt löschen" })); + const dialog = screen.getByRole("dialog", { name: "Widget Service löschen?" }); + expect(deleteProjectMock).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole("button", { name: "Abbrechen" })); + expect(screen.queryByRole("dialog", { name: "Widget Service löschen?" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Projekt löschen" })); + fireEvent.click(within(screen.getByRole("dialog", { name: "Widget Service löschen?" })).getByRole("button", { name: "Projekt löschen" })); + await waitFor(() => expect(deleteProjectMock).toHaveBeenCalledWith("project-1")); + }); + + it("localizes setup progress and preserves a provider failure verbatim", async () => { + vi.useFakeTimers(); + vi.mocked(fetchProjectInvocations).mockResolvedValue([{ + id: "invocation-1", + status: "failed", + lastErrorMessage: "provider diagnostic 42", + } as any]); + render(, "de"); + + fireEvent.click(screen.getByRole("button", { name: "Projekt einrichten" })); + fireEvent.click(within(screen.getByRole("dialog", { name: "Widget Service einrichten" })).getByRole("button", { name: "Projekt einrichten" })); + await vi.runAllTimersAsync(); + + expect(startProjectSetup).toHaveBeenCalledWith("project-1", { + enabled: true, + options: expect.objectContaining({ techstack: true, docs: false }), + }); + expect(addToastMock).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining("wird gestartet"), + })); + expect(addToastMock).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining("Aufruf invocati"), + })); + expect(addToastMock).toHaveBeenCalledWith(expect.objectContaining({ + type: "error", + message: expect.stringContaining("provider diagnostic 42"), + })); + vi.useRealTimers(); + }); }); diff --git a/tests/dashboard/v2/top-nav-selectors.test.tsx b/tests/dashboard/v2/top-nav-selectors.test.tsx index 3a881168e3..d7121bb320 100644 --- a/tests/dashboard/v2/top-nav-selectors.test.tsx +++ b/tests/dashboard/v2/top-nav-selectors.test.tsx @@ -10,6 +10,8 @@ import { useProjectData } from "../../../dashboard/src/v2/context/project-data.j import { useSprints } from "../../../dashboard/src/hooks/useSprints.js"; import { useProjectEffectiveSettings, clearProjectEffectiveSettingsCache } from "../../../dashboard/src/v2/hooks/use-project-effective-settings.js"; import { saveProjectDesignGuidanceSettings } from "../../../dashboard/src/v2/lib/settings-api.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/context.js"; +import type { DashboardLocale } from "../../../dashboard/src/v2/i18n/locales.js"; import { CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, DESIGN_GUIDANCE_NONE_ID, @@ -178,7 +180,7 @@ const renderTopNav = ({ sprints = [sprintOne], selectedSprintId = "sprint-1", effectiveLoading = false, -} = {}) => { +} = {}, locale: DashboardLocale = "en") => { vi.mocked(useProjectData).mockReturnValue({ projects, selectedProject, @@ -218,7 +220,11 @@ const renderTopNav = ({ refresh: refreshEffectiveSettings, } as any); - return render(); + return render( + + + , + ); }; describe("TopNav guidance and sprint selectors", () => { @@ -421,4 +427,16 @@ describe("TopNav guidance and sprint selectors", () => { expect(screen.getByRole("status")).toHaveTextContent("Sprint switched to Build shell"); }); }); + + it("reuses the translated Add Project modal from the global project selector", async () => { + const user = userEvent.setup(); + renderTopNav({}, "de"); + + await user.click(screen.getByRole("button", { name: /Project selector, selected project: Alpha/i })); + await user.click(screen.getByRole("button", { name: "Add Project" })); + + expect(await screen.findByRole("dialog", { name: /Projekt hinzufügen/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/Projektname/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Lokales Projekt" })).toBeInTheDocument(); + }); }); diff --git a/tests/e2e/projects/project-crud.spec.ts b/tests/e2e/projects/project-crud.spec.ts index d4dffc9f3b..902cdb1a77 100644 --- a/tests/e2e/projects/project-crud.spec.ts +++ b/tests/e2e/projects/project-crud.spec.ts @@ -13,6 +13,7 @@ async function prepareProjectsPage(page: Page, request: APIRequestContext): Prom await suppressDashboardTour(page); await page.addInitScript(() => { localStorage.setItem('codeux:sidebar:minimized', 'false'); + localStorage.setItem('codeux.dashboard.locale.v1', 'de'); }); } @@ -35,18 +36,18 @@ async function findProjectByName(request: APIRequestContext, projectName: string } function projectCard(page: Page, projectName: string): Locator { - return page.getByRole('article', { name: `Project: ${projectName}`, exact: true }); + return page.getByRole('article', { name: `Projekt: ${projectName}`, exact: true }); } async function selectProjectCard(page: Page, projectName: string): Promise { const card = projectCard(page, projectName); - const selectedButton = card.getByRole('button', { name: `Selected project: ${projectName}`, exact: true }); + const selectedButton = card.getByRole('button', { name: `Ausgewähltes Projekt: ${projectName}`, exact: true }); if (await selectedButton.isVisible()) { await selectedButton.click(); return; } - await card.getByRole('button', { name: `Select project: ${projectName}`, exact: true }).click(); + await card.getByRole('button', { name: `Projekt auswählen: ${projectName}`, exact: true }).click(); await expect(selectedButton).toBeVisible(); } @@ -64,28 +65,28 @@ test.describe('project CRUD lifecycle', () => { } }); - test('creates, selects, and deletes a local project through the Projects UI', async ({ page, request }, testInfo) => { + test('creates, selects, and deletes a local project through the German Projects UI', async ({ page, request }, testInfo) => { const prefix = createE2eFixturePrefix({ testInfo, fixtureKey: 'project-crud' }); const projectName = `${prefix} local checkout`; const checkoutPath = process.cwd(); await page.goto('/projects'); await hideDashboardAssistant(page); - await expect(page.getByRole('heading', { name: 'Manage Projects' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Projekte verwalten' })).toBeVisible(); - await page.getByRole('button', { name: 'Add Project' }).last().click(); - const dialog = page.getByRole('dialog', { name: /Add Project/i }); + await page.getByRole('button', { name: 'Projekt hinzufügen' }).last().click(); + const dialog = page.getByRole('dialog', { name: /Projekt hinzufügen/i }); await expect(dialog).toBeVisible(); - await dialog.getByLabel(/Project Name/).fill(projectName); - await dialog.getByRole('button', { name: 'Local Project' }).click(); - await dialog.getByLabel(/Directory Path/).fill(checkoutPath); + await dialog.getByLabel(/Projektname/).fill(projectName); + await dialog.getByRole('button', { name: 'Lokales Projekt' }).click(); + await dialog.getByLabel(/Verzeichnispfad/).fill(checkoutPath); - const setupCheckbox = dialog.getByLabel(/Initialize with Project Setup Agent/); + const setupCheckbox = dialog.getByLabel(/Mit Projekteinrichtungs-Agent initialisieren/); await expect(setupCheckbox).toBeChecked(); - await dialog.getByText('Initialize with Project Setup Agent').click(); + await dialog.getByText('Mit Projekteinrichtungs-Agent initialisieren').click(); await expect(setupCheckbox).not.toBeChecked(); - await dialog.getByRole('button', { name: 'Add Project' }).click(); + await dialog.getByRole('button', { name: 'Projekt hinzufügen' }).click(); await expect(dialog).toBeHidden(); const createdProject = await findProjectByName(request, projectName); @@ -105,7 +106,10 @@ test.describe('project CRUD lifecycle', () => { return projects.selectedProjectId; }).toBe(createdProject.id); - await projectCard(page, projectName).getByRole('button', { name: 'Delete project' }).click(); + await projectCard(page, projectName).getByRole('button', { name: 'Projekt löschen' }).click(); + const deleteDialog = page.getByRole('dialog', { name: `${projectName} löschen?`, exact: true }); + await expect(deleteDialog).toBeVisible(); + await deleteDialog.getByRole('button', { name: 'Projekt löschen' }).click(); await expect(projectCard(page, projectName)).toHaveCount(0); await expect.poll(async () => { From 1b8ff9ebbad19475e7256ce0342c345127f5dc96 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 02:23:03 +0000 Subject: [PATCH 2/3] fix(task T10): address qa review via codex --- dashboard/src/v2/ProjectsPage.tsx | 8 ++----- .../src/v2/components/ui/AddProjectModal.tsx | 3 +-- dashboard/src/v2/i18n/locales.ts | 3 ++- .../dashboard-internationalization.md | 2 +- ...tecture-dashboard-internationalization.mdx | 2 +- docs/dashboard/internationalization.md | 2 +- tests/dashboard/v2/add-project-modal.test.tsx | 18 +++++++++++++++ tests/dashboard/v2/i18n-foundation.test.tsx | 1 + tests/dashboard/v2/projects-page.test.tsx | 23 +++++++++++++++++++ 9 files changed, 50 insertions(+), 12 deletions(-) diff --git a/dashboard/src/v2/ProjectsPage.tsx b/dashboard/src/v2/ProjectsPage.tsx index 5a93de092a..756e67f9ec 100644 --- a/dashboard/src/v2/ProjectsPage.tsx +++ b/dashboard/src/v2/ProjectsPage.tsx @@ -274,16 +274,12 @@ export const ProjectsPage: FunctionComponent = () => { {viewModel.runningCount > 0 ? ( ) : null}