mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 14:37:44 +02:00
feat(i18n): translate modals namespace
Translates all 11 modal sources: registry (no UI text), backlog-agent-hint, set-parent-issue, add-child-issue, delete-issue-confirm, feedback, issue-picker, create-workspace, create-project, create-issue (manual), quick-create-issue (agent panel). Notable patterns: - create-project re-uses useProjectStatusLabels / useProjectPriorityLabels hooks from views/projects/components/labels — same translation source as the projects list / detail, no duplication. - create-issue.tsx: renamed `toast.custom((t) => ...)` callback param to `toastId` to avoid shadowing the closure-captured useT() `t` function. - Test wrapper added to modals/create-issue.test.tsx so the two assertions on rendered modal text (success toast + Create another) match the EN bundle. modals/create-workspace.test.tsx was already wrapped (workspace ns commit). Verified: pnpm --filter @multica/views typecheck (clean) + test (238/238). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,16 @@ const TRANSLATED_FILES = [
|
||||
"chat/components/no-agent-banner.tsx",
|
||||
"chat/components/offline-banner.tsx",
|
||||
"chat/components/task-status-pill.tsx",
|
||||
"modals/backlog-agent-hint.tsx",
|
||||
"modals/set-parent-issue.tsx",
|
||||
"modals/add-child-issue.tsx",
|
||||
"modals/delete-issue-confirm.tsx",
|
||||
"modals/feedback.tsx",
|
||||
"modals/issue-picker-modal.tsx",
|
||||
"modals/create-workspace.tsx",
|
||||
"modals/create-project.tsx",
|
||||
"modals/create-issue.tsx",
|
||||
"modals/quick-create-issue.tsx",
|
||||
];
|
||||
|
||||
export default [
|
||||
|
||||
@@ -17,6 +17,7 @@ import type projects from "../locales/en/projects.json";
|
||||
import type autopilots from "../locales/en/autopilots.json";
|
||||
import type skills from "../locales/en/skills.json";
|
||||
import type chat from "../locales/en/chat.json";
|
||||
import type modals from "../locales/en/modals.json";
|
||||
|
||||
// Module augmentation enables i18next v26 selector API across the monorepo:
|
||||
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
|
||||
@@ -48,6 +49,7 @@ declare module "i18next" {
|
||||
autopilots: typeof autopilots;
|
||||
skills: typeof skills;
|
||||
chat: typeof chat;
|
||||
modals: typeof modals;
|
||||
};
|
||||
enableSelector: true;
|
||||
}
|
||||
|
||||
128
packages/views/locales/en/modals.json
Normal file
128
packages/views/locales/en/modals.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"common": {
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"expand_tooltip": "Expand",
|
||||
"collapse_tooltip": "Collapse"
|
||||
},
|
||||
"create_workspace": {
|
||||
"title": "Create a new workspace",
|
||||
"description": "Workspaces are shared environments where teams can work on projects and issues."
|
||||
},
|
||||
"delete_issue": {
|
||||
"title": "Delete issue",
|
||||
"description": "This will permanently delete this issue and all its comments. This action cannot be undone.",
|
||||
"hint": "Any workspace member can delete issues.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
"toast_deleted": "Issue deleted",
|
||||
"toast_delete_failed": "Failed to delete issue"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"description": "We'd love to hear what's working, what isn't, or what you'd like to see next.",
|
||||
"placeholder": "Tell us about your experience, bugs you've found, or features you'd like to see…",
|
||||
"toast_uploading": "Please wait for uploads to finish…",
|
||||
"toast_too_long": "Message is too long",
|
||||
"toast_sent": "Thanks for the feedback!",
|
||||
"toast_failed": "Failed to send feedback",
|
||||
"send": "Send feedback",
|
||||
"sending": "Sending…"
|
||||
},
|
||||
"issue_picker": {
|
||||
"search_placeholder": "Search issues...",
|
||||
"searching": "Searching...",
|
||||
"no_results": "No issues found.",
|
||||
"prompt_to_search": "Type to search issues"
|
||||
},
|
||||
"set_parent": {
|
||||
"title": "Set parent issue",
|
||||
"description": "Search for an issue to set as the parent of this issue",
|
||||
"toast_failed": "Failed to update issue",
|
||||
"toast_success": "Set {{identifier}} as parent issue"
|
||||
},
|
||||
"add_child": {
|
||||
"title": "Add sub-issue",
|
||||
"description": "Search for an issue to add as a sub-issue",
|
||||
"toast_failed": "Failed to add sub-issue",
|
||||
"toast_success": "Added {{identifier}} as sub-issue"
|
||||
},
|
||||
"backlog_hint": {
|
||||
"toast_status_failed": "Failed to update status"
|
||||
},
|
||||
"create_project": {
|
||||
"title": "New Project",
|
||||
"title_breadcrumb": "New project",
|
||||
"icon_tooltip": "Choose icon",
|
||||
"title_placeholder": "Project title",
|
||||
"description_placeholder": "Add description...",
|
||||
"lead": "Lead",
|
||||
"no_lead": "No lead",
|
||||
"lead_placeholder": "Assign lead...",
|
||||
"members_group": "Members",
|
||||
"agents_group": "Agents",
|
||||
"no_results": "No results",
|
||||
"submit": "Create Project",
|
||||
"submitting": "Creating...",
|
||||
"toast_created": "Project created",
|
||||
"toast_failed": "Failed to create project"
|
||||
},
|
||||
"create_issue": {
|
||||
"sr_manual": "New Issue",
|
||||
"sr_agent": "Quick create issue",
|
||||
"manual_breadcrumb": "Create manually",
|
||||
"agent_breadcrumb": "Create with agent",
|
||||
"title_placeholder": "Issue title",
|
||||
"description_placeholder": "Add description...",
|
||||
"more_options_aria": "More options",
|
||||
"submit": "Create Issue",
|
||||
"submitting": "Creating...",
|
||||
"toast_created": "Issue created",
|
||||
"view_issue": "View issue",
|
||||
"toast_failed": "Failed to create issue",
|
||||
"toast_link_subissues_all_failed": "Failed to link sub-issues",
|
||||
"toast_link_subissues_partial": "Failed to link {{failed}} of {{total}} sub-issues",
|
||||
"switch_to_agent": "Switch to Agent",
|
||||
"switch_to_agent_tooltip": "Switch to create with agent — describe in one line and let the agent file it",
|
||||
"switch_to_manual": "Switch to Manual",
|
||||
"switch_to_manual_tooltip": "Switch to manual create — fill the fields yourself",
|
||||
"create_another": "Create another",
|
||||
"remove_parent_aria": "Remove parent",
|
||||
"remove_subissue_aria": "Remove sub-issue {{identifier}}",
|
||||
"subissue_of": "Sub-issue of {{identifier}}",
|
||||
"subissue_chip": "Sub-issue: {{identifier}}",
|
||||
"parent_with_id": "Parent: {{identifier}}",
|
||||
"set_parent": "Set parent issue...",
|
||||
"add_subissue": "Add sub-issue...",
|
||||
"remove_parent": "Remove parent",
|
||||
"set_parent_picker": {
|
||||
"title": "Set parent issue",
|
||||
"description": "Search for an issue to set as the parent of the new issue"
|
||||
},
|
||||
"add_subissue_picker": {
|
||||
"title": "Add sub-issue",
|
||||
"description": "Search for an issue to add as a sub-issue of the new issue"
|
||||
},
|
||||
"agent": {
|
||||
"created_by": "Created by",
|
||||
"select_agent_aria": "Select agent",
|
||||
"pick_an_agent": "Pick an agent…",
|
||||
"no_agents": "No agents available.",
|
||||
"version_missing": "This agent's daemon doesn't report a CLI version. Create with agent needs multica CLI ≥ {{min}}. Upgrade the daemon and reconnect, or switch to manual create.",
|
||||
"version_below": "This agent's daemon CLI is {{current}} — Create with agent needs ≥ {{min}}. Upgrade the daemon, or switch to manual create.",
|
||||
"prompt_placeholder": "Tell the agent what to do, e.g. \"let Bohan fix the inbox loading slowness in the Web project\"",
|
||||
"submit": "Create (⌘↵)",
|
||||
"sending": "Sending…",
|
||||
"uploading": "Uploading…",
|
||||
"sent_label": "Sent",
|
||||
"sent_count": "{{count}} sent",
|
||||
"version_blocked_tooltip": "Daemon CLI must be ≥ {{min}}",
|
||||
"toast_sent": "Sent to agent — you'll get an inbox notification when it's done",
|
||||
"error_agent_unavailable_fallback": "Agent is unavailable. Pick another agent.",
|
||||
"error_daemon_version": "This agent's daemon CLI ({{current}}) is below the required {{min}}. Upgrade the daemon to use Create with agent.",
|
||||
"error_unknown": "Failed to submit. Try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import enProjects from "./en/projects.json";
|
||||
import enAutopilots from "./en/autopilots.json";
|
||||
import enSkills from "./en/skills.json";
|
||||
import enChat from "./en/chat.json";
|
||||
import enModals from "./en/modals.json";
|
||||
import zhHansCommon from "./zh-Hans/common.json";
|
||||
import zhHansAuth from "./zh-Hans/auth.json";
|
||||
import zhHansSettings from "./zh-Hans/settings.json";
|
||||
@@ -29,6 +30,7 @@ import zhHansProjects from "./zh-Hans/projects.json";
|
||||
import zhHansAutopilots from "./zh-Hans/autopilots.json";
|
||||
import zhHansSkills from "./zh-Hans/skills.json";
|
||||
import zhHansChat from "./zh-Hans/chat.json";
|
||||
import zhHansModals from "./zh-Hans/modals.json";
|
||||
|
||||
// Single source of truth for the resource bundle. Both apps (web layout +
|
||||
// desktop App.tsx) import from here so adding a locale or namespace happens
|
||||
@@ -50,6 +52,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
autopilots: enAutopilots,
|
||||
skills: enSkills,
|
||||
chat: enChat,
|
||||
modals: enModals,
|
||||
},
|
||||
"zh-Hans": {
|
||||
common: zhHansCommon,
|
||||
@@ -67,5 +70,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
autopilots: zhHansAutopilots,
|
||||
skills: zhHansSkills,
|
||||
chat: zhHansChat,
|
||||
modals: zhHansModals,
|
||||
},
|
||||
};
|
||||
|
||||
128
packages/views/locales/zh-Hans/modals.json
Normal file
128
packages/views/locales/zh-Hans/modals.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"common": {
|
||||
"back": "返回",
|
||||
"close": "关闭",
|
||||
"cancel": "取消",
|
||||
"expand_tooltip": "展开",
|
||||
"collapse_tooltip": "收起"
|
||||
},
|
||||
"create_workspace": {
|
||||
"title": "创建新工作区",
|
||||
"description": "工作区是团队协作处理 project 和 issue 的共享空间。"
|
||||
},
|
||||
"delete_issue": {
|
||||
"title": "删除 issue",
|
||||
"description": "将永久删除该 issue 及其所有评论。此操作不可撤销。",
|
||||
"hint": "工作区任何成员都可以删除 issue。",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除",
|
||||
"deleting": "删除中...",
|
||||
"toast_deleted": "已删除 issue",
|
||||
"toast_delete_failed": "删除 issue 失败"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "反馈",
|
||||
"description": "告诉我们哪些好用、哪些不好用、或者你希望我们做什么。",
|
||||
"placeholder": "聊聊你的体验、遇到的 bug,或想看到的功能…",
|
||||
"toast_uploading": "请等待上传完成…",
|
||||
"toast_too_long": "内容过长",
|
||||
"toast_sent": "感谢反馈!",
|
||||
"toast_failed": "发送反馈失败",
|
||||
"send": "发送反馈",
|
||||
"sending": "发送中…"
|
||||
},
|
||||
"issue_picker": {
|
||||
"search_placeholder": "搜索 issue...",
|
||||
"searching": "搜索中...",
|
||||
"no_results": "未找到 issue。",
|
||||
"prompt_to_search": "输入关键词搜索 issue"
|
||||
},
|
||||
"set_parent": {
|
||||
"title": "设置父 issue",
|
||||
"description": "搜索一个 issue,将其设为当前 issue 的父级",
|
||||
"toast_failed": "更新 issue 失败",
|
||||
"toast_success": "已将 {{identifier}} 设为父 issue"
|
||||
},
|
||||
"add_child": {
|
||||
"title": "添加子 issue",
|
||||
"description": "搜索一个 issue 添加为子 issue",
|
||||
"toast_failed": "添加子 issue 失败",
|
||||
"toast_success": "已添加 {{identifier}} 为子 issue"
|
||||
},
|
||||
"backlog_hint": {
|
||||
"toast_status_failed": "更新状态失败"
|
||||
},
|
||||
"create_project": {
|
||||
"title": "新建 project",
|
||||
"title_breadcrumb": "新建 project",
|
||||
"icon_tooltip": "选择图标",
|
||||
"title_placeholder": "project 标题",
|
||||
"description_placeholder": "添加描述...",
|
||||
"lead": "负责人",
|
||||
"no_lead": "无负责人",
|
||||
"lead_placeholder": "指派负责人...",
|
||||
"members_group": "成员",
|
||||
"agents_group": "智能体",
|
||||
"no_results": "未找到结果",
|
||||
"submit": "创建 project",
|
||||
"submitting": "创建中...",
|
||||
"toast_created": "已创建 project",
|
||||
"toast_failed": "创建 project 失败"
|
||||
},
|
||||
"create_issue": {
|
||||
"sr_manual": "新建 issue",
|
||||
"sr_agent": "快速创建 issue",
|
||||
"manual_breadcrumb": "手动创建",
|
||||
"agent_breadcrumb": "通过智能体创建",
|
||||
"title_placeholder": "issue 标题",
|
||||
"description_placeholder": "添加描述...",
|
||||
"more_options_aria": "更多选项",
|
||||
"submit": "创建 issue",
|
||||
"submitting": "创建中...",
|
||||
"toast_created": "已创建 issue",
|
||||
"view_issue": "查看 issue",
|
||||
"toast_failed": "创建 issue 失败",
|
||||
"toast_link_subissues_all_failed": "关联子 issue 失败",
|
||||
"toast_link_subissues_partial": "{{total}} 个子 issue 中有 {{failed}} 个关联失败",
|
||||
"switch_to_agent": "切换到智能体",
|
||||
"switch_to_agent_tooltip": "切换到通过智能体创建——一句话描述,让它替你建",
|
||||
"switch_to_manual": "切换到手动",
|
||||
"switch_to_manual_tooltip": "切换到手动创建——自己填字段",
|
||||
"create_another": "继续创建",
|
||||
"remove_parent_aria": "移除父级",
|
||||
"remove_subissue_aria": "移除子 issue {{identifier}}",
|
||||
"subissue_of": "{{identifier}} 的子 issue",
|
||||
"subissue_chip": "子 issue:{{identifier}}",
|
||||
"parent_with_id": "父:{{identifier}}",
|
||||
"set_parent": "设置父 issue...",
|
||||
"add_subissue": "添加子 issue...",
|
||||
"remove_parent": "移除父级",
|
||||
"set_parent_picker": {
|
||||
"title": "设置父 issue",
|
||||
"description": "搜索一个 issue,作为新 issue 的父级"
|
||||
},
|
||||
"add_subissue_picker": {
|
||||
"title": "添加子 issue",
|
||||
"description": "搜索一个 issue,作为新 issue 的子级"
|
||||
},
|
||||
"agent": {
|
||||
"created_by": "创建者",
|
||||
"select_agent_aria": "选择智能体",
|
||||
"pick_an_agent": "选一个智能体…",
|
||||
"no_agents": "暂无可用智能体。",
|
||||
"version_missing": "该智能体的 daemon 没有报告 CLI 版本。通过智能体创建需要 multica CLI ≥ {{min}}。请升级 daemon 并重连,或切换到手动创建。",
|
||||
"version_below": "该智能体的 daemon CLI 是 {{current}}——通过智能体创建需要 ≥ {{min}}。请升级 daemon,或切换到手动创建。",
|
||||
"prompt_placeholder": "告诉智能体要做什么,例如:\"让 Bohan 修一下 Web 项目里收件箱加载慢的问题\"",
|
||||
"submit": "创建(⌘↵)",
|
||||
"sending": "发送中…",
|
||||
"uploading": "上传中…",
|
||||
"sent_label": "已发送",
|
||||
"sent_count": "已发送 {{count}}",
|
||||
"version_blocked_tooltip": "daemon CLI 必须 ≥ {{min}}",
|
||||
"toast_sent": "已交给智能体——完成后会在收件箱通知你",
|
||||
"error_agent_unavailable_fallback": "智能体不可用,请换一个。",
|
||||
"error_daemon_version": "该智能体的 daemon CLI({{current}})低于要求的 {{min}}。请升级 daemon 后再使用通过智能体创建。",
|
||||
"error_unknown": "提交失败,请重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { IssuePickerModal } from "./issue-picker-modal";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export function AddChildIssueModal({
|
||||
onClose,
|
||||
@@ -17,6 +18,7 @@ export function AddChildIssueModal({
|
||||
onClose: () => void;
|
||||
data: Record<string, unknown> | null;
|
||||
}) {
|
||||
const { t } = useT("modals");
|
||||
const issueId = (data?.issueId as string) || "";
|
||||
const wsId = useWorkspaceId();
|
||||
const updateIssue = useUpdateIssue();
|
||||
@@ -42,15 +44,15 @@ export function AddChildIssueModal({
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose();
|
||||
}}
|
||||
title="Add sub-issue"
|
||||
description="Search for an issue to add as a sub-issue"
|
||||
title={t(($) => $.add_child.title)}
|
||||
description={t(($) => $.add_child.description)}
|
||||
excludeIds={excludeIds}
|
||||
onSelect={(selected) => {
|
||||
updateIssue.mutate(
|
||||
{ id: selected.id, parent_issue_id: issueId },
|
||||
{ onError: () => toast.error("Failed to add sub-issue") },
|
||||
{ onError: () => toast.error(t(($) => $.add_child.toast_failed)) },
|
||||
);
|
||||
toast.success(`Added ${selected.identifier} as sub-issue`);
|
||||
toast.success(t(($) => $.add_child.toast_success, { identifier: selected.identifier }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { toast } from "sonner";
|
||||
import { BacklogAgentHintDialog } from "../issues/components/backlog-agent-hint-dialog";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export function BacklogAgentHintModal({
|
||||
onClose,
|
||||
@@ -11,6 +12,7 @@ export function BacklogAgentHintModal({
|
||||
onClose: () => void;
|
||||
data: Record<string, unknown> | null;
|
||||
}) {
|
||||
const { t } = useT("modals");
|
||||
const issueId = (data?.issueId as string) || "";
|
||||
const updateIssue = useUpdateIssue();
|
||||
|
||||
@@ -27,7 +29,7 @@ export function BacklogAgentHintModal({
|
||||
if (issueId) {
|
||||
updateIssue.mutate(
|
||||
{ id: issueId, status: "todo" },
|
||||
{ onError: () => toast.error("Failed to update status") },
|
||||
{ onError: () => toast.error(t(($) => $.backlog_hint.toast_status_failed)) },
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../locales/en/common.json";
|
||||
import enModals from "../locales/en/modals.json";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, modals: enModals },
|
||||
};
|
||||
|
||||
function I18nWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn());
|
||||
const mockCreateIssue = vi.hoisted(() => vi.fn());
|
||||
@@ -236,7 +251,11 @@ function renderModal(element: React.ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{element}</QueryClientProvider>);
|
||||
return render(
|
||||
<I18nWrapper>
|
||||
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
|
||||
</I18nWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateIssueModal", () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ import { api } from "@multica/core/api";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { PillButton } from "../common/pill-button";
|
||||
import { IssuePickerModal } from "./issue-picker-modal";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ManualCreatePanel — manual-mode body of the create-issue dialog. Renders
|
||||
@@ -77,6 +78,7 @@ export function ManualCreatePanel({
|
||||
backlogHintIssueId: string | null;
|
||||
setBacklogHintIssueId: (id: string | null) => void;
|
||||
}) {
|
||||
const { t } = useT("modals");
|
||||
const router = useNavigation();
|
||||
const p = useWorkspacePaths();
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
@@ -198,8 +200,11 @@ export function ManualCreatePanel({
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
failed === childIssues.length
|
||||
? "Failed to link sub-issues"
|
||||
: `Failed to link ${failed} of ${childIssues.length} sub-issues`,
|
||||
? t(($) => $.create_issue.toast_link_subissues_all_failed)
|
||||
: t(($) => $.create_issue.toast_link_subissues_partial, {
|
||||
failed,
|
||||
total: childIssues.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -220,13 +225,13 @@ export function ManualCreatePanel({
|
||||
}
|
||||
|
||||
if (!shouldShowBacklogHint) {
|
||||
toast.custom((t) => (
|
||||
toast.custom((toastId) => (
|
||||
<div className="bg-popover text-popover-foreground border rounded-lg shadow-lg p-4 w-[360px]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center justify-center size-5 rounded-full bg-emerald-500/15 text-emerald-500">
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Issue created</span>
|
||||
<span className="text-sm font-medium">{t(($) => $.create_issue.toast_created)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
||||
<StatusIcon status={issue.status} className="size-3.5 shrink-0" />
|
||||
@@ -237,16 +242,16 @@ export function ManualCreatePanel({
|
||||
className="ml-7 mt-2 text-sm text-primary hover:underline cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push(p.issueDetail(issue.id));
|
||||
toast.dismiss(t);
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
>
|
||||
View issue
|
||||
{t(($) => $.create_issue.view_issue)}
|
||||
</button>
|
||||
</div>
|
||||
), { duration: 5000 });
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create issue");
|
||||
toast.error(t(($) => $.create_issue.toast_failed));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -283,7 +288,7 @@ export function ManualCreatePanel({
|
||||
onMoveToTodo={() => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: backlogHintIssueId, status: "todo" },
|
||||
{ onError: () => toast.error("Failed to update status") },
|
||||
{ onError: () => toast.error(t(($) => $.backlog_hint.toast_status_failed)) },
|
||||
);
|
||||
setBacklogHintIssueId(null);
|
||||
onClose();
|
||||
@@ -291,14 +296,14 @@ export function ManualCreatePanel({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DialogTitle className="sr-only">New Issue</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t(($) => $.create_issue.sr_manual)}</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">Create manually</span>
|
||||
<span className="font-medium">{t(($) => $.create_issue.manual_breadcrumb)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
@@ -312,7 +317,11 @@ export function ManualCreatePanel({
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{isExpanded
|
||||
? t(($) => $.common.collapse_tooltip)
|
||||
: t(($) => $.common.expand_tooltip)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -325,7 +334,7 @@ export function ManualCreatePanel({
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
<TooltipContent side="bottom">{t(($) => $.common.close)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,7 +345,7 @@ export function ManualCreatePanel({
|
||||
key={formResetKey}
|
||||
autoFocus
|
||||
defaultValue={draft.title}
|
||||
placeholder="Issue title"
|
||||
placeholder={t(($) => $.create_issue.title_placeholder)}
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => updateTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -348,7 +357,7 @@ export function ManualCreatePanel({
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue={draft.description}
|
||||
placeholder="Add description..."
|
||||
placeholder={t(($) => $.create_issue.description_placeholder)}
|
||||
onUpdate={(md) => setDraft({ description: md })}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={500}
|
||||
@@ -413,13 +422,15 @@ export function ManualCreatePanel({
|
||||
className="flex items-center gap-1.5 py-1 pl-2.5 cursor-pointer"
|
||||
>
|
||||
<ArrowUp className="size-3 text-muted-foreground" />
|
||||
<span>Sub-issue of {parentIssue.identifier}</span>
|
||||
<span>
|
||||
{t(($) => $.create_issue.subissue_of, { identifier: parentIssue.identifier })}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParentIssueId(undefined)}
|
||||
className="p-1 pr-2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
aria-label="Remove parent"
|
||||
aria-label={t(($) => $.create_issue.remove_parent_aria)}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
@@ -435,7 +446,7 @@ export function ManualCreatePanel({
|
||||
>
|
||||
<div className="flex items-center gap-1.5 py-1 pl-2.5">
|
||||
<ArrowDown className="size-3 text-muted-foreground" />
|
||||
<span>Sub-issue: {c.identifier}</span>
|
||||
<span>{t(($) => $.create_issue.subissue_chip, { identifier: c.identifier })}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -443,7 +454,7 @@ export function ManualCreatePanel({
|
||||
setChildIssues((prev) => prev.filter((x) => x.id !== c.id))
|
||||
}
|
||||
className="p-1 pr-2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
aria-label={`Remove sub-issue ${c.identifier}`}
|
||||
aria-label={t(($) => $.create_issue.remove_subissue_aria, { identifier: c.identifier })}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
@@ -455,7 +466,7 @@ export function ManualCreatePanel({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton aria-label="More options">
|
||||
<PillButton aria-label={t(($) => $.create_issue.more_options_aria)}>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</PillButton>
|
||||
}
|
||||
@@ -464,17 +475,17 @@ export function ManualCreatePanel({
|
||||
{parentIssueId && parentIssue ? (
|
||||
<DropdownMenuItem onClick={() => setParentPickerOpen(true)}>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
Parent: {parentIssue.identifier}
|
||||
{t(($) => $.create_issue.parent_with_id, { identifier: parentIssue.identifier })}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => setParentPickerOpen(true)}>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
Set parent issue...
|
||||
{t(($) => $.create_issue.set_parent)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setChildPickerOpen(true)}>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
Add sub-issue...
|
||||
{t(($) => $.create_issue.add_subissue)}
|
||||
</DropdownMenuItem>
|
||||
{parentIssueId && parentIssue && (
|
||||
<>
|
||||
@@ -484,7 +495,7 @@ export function ManualCreatePanel({
|
||||
onClick={() => setParentIssueId(undefined)}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
Remove parent
|
||||
{t(($) => $.create_issue.remove_parent)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -497,8 +508,8 @@ export function ManualCreatePanel({
|
||||
<IssuePickerModal
|
||||
open={parentPickerOpen}
|
||||
onOpenChange={setParentPickerOpen}
|
||||
title="Set parent issue"
|
||||
description="Search for an issue to set as the parent of the new issue"
|
||||
title={t(($) => $.create_issue.set_parent_picker.title)}
|
||||
description={t(($) => $.create_issue.set_parent_picker.description)}
|
||||
excludeIds={[
|
||||
...childIssues.map((c) => c.id),
|
||||
...(parentIssueId ? [parentIssueId] : []),
|
||||
@@ -510,8 +521,8 @@ export function ManualCreatePanel({
|
||||
<IssuePickerModal
|
||||
open={childPickerOpen}
|
||||
onOpenChange={setChildPickerOpen}
|
||||
title="Add sub-issue"
|
||||
description="Search for an issue to add as a sub-issue of the new issue"
|
||||
title={t(($) => $.create_issue.add_subissue_picker.title)}
|
||||
description={t(($) => $.create_issue.add_subissue_picker.description)}
|
||||
excludeIds={[
|
||||
...childIssues.map((c) => c.id),
|
||||
...(parentIssueId ? [parentIssueId] : []),
|
||||
@@ -534,11 +545,11 @@ export function ManualCreatePanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToAgent}
|
||||
title="Switch to create with agent — describe in one line and let the agent file it"
|
||||
title={t(($) => $.create_issue.switch_to_agent_tooltip)}
|
||||
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to Agent
|
||||
{t(($) => $.create_issue.switch_to_agent)}
|
||||
</button>
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<Switch
|
||||
@@ -546,10 +557,10 @@ export function ManualCreatePanel({
|
||||
checked={keepOpen}
|
||||
onCheckedChange={setKeepOpen}
|
||||
/>
|
||||
Create another
|
||||
{t(($) => $.create_issue.create_another)}
|
||||
</label>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
{submitting ? t(($) => $.create_issue.submitting) : t(($) => $.create_issue.submit)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useProjectDraftStore } from "@multica/core/projects";
|
||||
import {
|
||||
PROJECT_STATUS_CONFIG,
|
||||
PROJECT_STATUS_ORDER,
|
||||
PROJECT_PRIORITY_CONFIG,
|
||||
PROJECT_PRIORITY_ORDER,
|
||||
} from "@multica/core/projects/config";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -33,6 +32,11 @@ import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor";
|
||||
import { PriorityIcon } from "../issues/components/priority-icon";
|
||||
import { ActorAvatar } from "../common/actor-avatar";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useT } from "../i18n";
|
||||
import {
|
||||
useProjectStatusLabels,
|
||||
useProjectPriorityLabels,
|
||||
} from "../projects/components/labels";
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
@@ -55,6 +59,7 @@ function PillButton({
|
||||
}
|
||||
|
||||
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useT("modals");
|
||||
const router = useNavigation();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const workspaceName = workspace?.name;
|
||||
@@ -63,6 +68,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
const projectStatusLabels = useProjectStatusLabels();
|
||||
const projectPriorityLabels = useProjectPriorityLabels();
|
||||
|
||||
const draft = useProjectDraftStore((s) => s.draft);
|
||||
const setDraft = useProjectDraftStore((s) => s.setDraft);
|
||||
@@ -98,7 +105,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
(a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery),
|
||||
);
|
||||
|
||||
const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead";
|
||||
const leadLabel =
|
||||
leadType && leadId ? getActorName(leadType, leadId) : t(($) => $.create_project.lead);
|
||||
|
||||
const createProject = useCreateProject();
|
||||
|
||||
@@ -117,10 +125,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
});
|
||||
clearDraft();
|
||||
onClose();
|
||||
toast.success("Project created");
|
||||
toast.success(t(($) => $.create_project.toast_created));
|
||||
router.push(wsPaths.projectDetail(project.id));
|
||||
} catch {
|
||||
toast.error("Failed to create project");
|
||||
toast.error(t(($) => $.create_project.toast_failed));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -139,13 +147,13 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Project</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t(($) => $.create_project.title)}</DialogTitle>
|
||||
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">New project</span>
|
||||
<span className="font-medium">{t(($) => $.create_project.title_breadcrumb)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
@@ -159,7 +167,11 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
{isExpanded
|
||||
? t(($) => $.common.collapse_tooltip)
|
||||
: t(($) => $.common.expand_tooltip)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -172,7 +184,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
<TooltipContent side="bottom">{t(($) => $.common.close)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +196,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
||||
title="Choose icon"
|
||||
title={t(($) => $.create_project.icon_tooltip)}
|
||||
>
|
||||
{icon || "📁"}
|
||||
</button>
|
||||
@@ -202,7 +214,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue={draft.title}
|
||||
placeholder="Project title"
|
||||
placeholder={t(($) => $.create_project.title_placeholder)}
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => updateTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -213,7 +225,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue={draft.description}
|
||||
placeholder="Add description..."
|
||||
placeholder={t(($) => $.create_project.description_placeholder)}
|
||||
onUpdate={(md) => setDraft({ description: md })}
|
||||
debounceMs={500}
|
||||
/>
|
||||
@@ -225,7 +237,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
render={
|
||||
<PillButton>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
||||
<span>{projectStatusLabels[status]}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
@@ -233,7 +245,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
<span>{projectStatusLabels[s]}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -244,7 +256,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
render={
|
||||
<PillButton>
|
||||
<PriorityIcon priority={priority} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
|
||||
<span>{projectPriorityLabels[priority]}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
@@ -252,7 +264,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
{PROJECT_PRIORITY_ORDER.map((pr) => (
|
||||
<DropdownMenuItem key={pr} onClick={() => updatePriority(pr)}>
|
||||
<PriorityIcon priority={pr} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
|
||||
<span>{projectPriorityLabels[pr]}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -274,7 +286,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<span>{leadLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Lead</span>
|
||||
<span className="text-muted-foreground">{t(($) => $.create_project.lead)}</span>
|
||||
)}
|
||||
</PillButton>
|
||||
}
|
||||
@@ -285,7 +297,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
type="text"
|
||||
value={leadFilter}
|
||||
onChange={(e) => setLeadFilter(e.target.value)}
|
||||
placeholder="Assign lead..."
|
||||
placeholder={t(($) => $.create_project.lead_placeholder)}
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -299,12 +311,12 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">No lead</span>
|
||||
<span className="text-muted-foreground">{t(($) => $.create_project.no_lead)}</span>
|
||||
</button>
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Members
|
||||
{t(($) => $.create_project.members_group)}
|
||||
</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
@@ -325,7 +337,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agents
|
||||
{t(($) => $.create_project.agents_group)}
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
@@ -347,7 +359,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
filteredAgents.length === 0 &&
|
||||
leadFilter && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No results
|
||||
{t(($) => $.create_project.no_results)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -357,7 +369,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Project"}
|
||||
{submitting ? t(($) => $.create_project.submitting) : t(($) => $.create_project.submit)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { CreateWorkspaceForm } from "../workspace/create-workspace-form";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useT("modals");
|
||||
const router = useNavigation();
|
||||
|
||||
return (
|
||||
@@ -43,18 +45,17 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
onClick={onClose}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
{t(($) => $.common.back)}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 pb-12">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<DialogTitle className="text-2xl font-semibold">
|
||||
Create a new workspace
|
||||
{t(($) => $.create_workspace.title)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2">
|
||||
Workspaces are shared environments where teams can work on
|
||||
projects and issues.
|
||||
{t(($) => $.create_workspace.description)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<CreateWorkspaceForm
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { useDeleteIssue } from "@multica/core/issues/mutations";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export function DeleteIssueConfirmModal({
|
||||
onClose,
|
||||
@@ -22,6 +23,7 @@ export function DeleteIssueConfirmModal({
|
||||
onClose: () => void;
|
||||
data: Record<string, unknown> | null;
|
||||
}) {
|
||||
const { t } = useT("modals");
|
||||
const issueId = (data?.issueId as string) || "";
|
||||
const navigateTo = (data?.onDeletedNavigateTo as string | undefined) || undefined;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -33,11 +35,11 @@ export function DeleteIssueConfirmModal({
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteIssue.mutateAsync(issueId);
|
||||
toast.success("Issue deleted");
|
||||
toast.success(t(($) => $.delete_issue.toast_deleted));
|
||||
onClose();
|
||||
if (navigateTo) navigation.push(navigateTo);
|
||||
} catch {
|
||||
toast.error("Failed to delete issue");
|
||||
toast.error(t(($) => $.delete_issue.toast_delete_failed));
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
@@ -46,22 +48,22 @@ export function DeleteIssueConfirmModal({
|
||||
<AlertDialog open onOpenChange={(v) => { if (!v && !deleting) onClose(); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete issue</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t(($) => $.delete_issue.title)}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this issue and all its comments. This action cannot be undone.
|
||||
{t(($) => $.delete_issue.description)}
|
||||
<span className="mt-2 block text-xs text-muted-foreground/80">
|
||||
Any workspace member can delete issues.
|
||||
{t(($) => $.delete_issue.hint)}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleting}>{t(($) => $.delete_issue.cancel)}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
{deleting ? t(($) => $.delete_issue.deleting) : t(($) => $.delete_issue.confirm)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -21,10 +21,12 @@ import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { captureFeedbackOpened } from "@multica/core/analytics";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const MAX_MESSAGE_LEN = 10000;
|
||||
|
||||
export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useT("modals");
|
||||
const workspace = useCurrentWorkspace();
|
||||
const draft = useFeedbackDraftStore((s) => s.draft);
|
||||
const setDraft = useFeedbackDraftStore((s) => s.setDraft);
|
||||
@@ -55,7 +57,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
if (editorRef.current?.hasActiveUploads()) {
|
||||
toast.info("Please wait for uploads to finish…");
|
||||
toast.info(t(($) => $.feedback.toast_uploading));
|
||||
return;
|
||||
}
|
||||
// Read from the editor ref at submit time — `message` state lags 150ms
|
||||
@@ -64,7 +66,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
const latest = editorRef.current?.getMarkdown()?.trim() ?? "";
|
||||
if (!latest) return;
|
||||
if (latest.length > MAX_MESSAGE_LEN) {
|
||||
toast.error("Message is too long");
|
||||
toast.error(t(($) => $.feedback.toast_too_long));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -74,13 +76,13 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
workspace_id: workspace?.id,
|
||||
});
|
||||
clearDraft();
|
||||
toast.success("Thanks for the feedback!");
|
||||
toast.success(t(($) => $.feedback.toast_sent));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: "Failed to send feedback";
|
||||
: t(($) => $.feedback.toast_failed);
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
@@ -89,10 +91,9 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl !h-[28rem] p-0 gap-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-4 pb-2 shrink-0">
|
||||
<DialogTitle>Feedback</DialogTitle>
|
||||
<DialogTitle>{t(($) => $.feedback.title)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'd love to hear what's working, what isn't, or
|
||||
what you'd like to see next.
|
||||
{t(($) => $.feedback.description)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -104,7 +105,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={draft.message}
|
||||
placeholder="Tell us about your experience, bugs you've found, or features you'd like to see…"
|
||||
placeholder={t(($) => $.feedback.placeholder)}
|
||||
onUpdate={(md) => { setMessage(md); setDraft({ message: md }); }}
|
||||
onUploadFile={uploadWithToast}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -118,7 +119,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{mutation.isPending ? "Sending…" : "Send feedback"}
|
||||
{mutation.isPending ? t(($) => $.feedback.sending) : t(($) => $.feedback.send)}
|
||||
<kbd className="ml-1 inline-flex h-4 items-center gap-0.5 rounded border border-border/50 bg-background/30 px-1 font-mono text-[10px] leading-none">
|
||||
⌘↵
|
||||
</kbd>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CommandItem,
|
||||
} from "@multica/ui/components/ui/command";
|
||||
import { StatusIcon } from "../issues/components/status-icon";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
interface IssuePickerModalProps {
|
||||
open: boolean;
|
||||
@@ -31,6 +32,7 @@ export function IssuePickerModal({
|
||||
excludeIds,
|
||||
onSelect,
|
||||
}: IssuePickerModalProps) {
|
||||
const { t } = useT("modals");
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Issue[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -90,7 +92,7 @@ export function IssuePickerModal({
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search issues..."
|
||||
placeholder={t(($) => $.issue_picker.search_placeholder)}
|
||||
value={query}
|
||||
onValueChange={(v) => {
|
||||
setQuery(v);
|
||||
@@ -100,15 +102,15 @@ export function IssuePickerModal({
|
||||
<CommandList>
|
||||
{isLoading && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
Searching...
|
||||
{t(($) => $.issue_picker.searching)}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && query.trim() && results.length === 0 && (
|
||||
<CommandEmpty>No issues found.</CommandEmpty>
|
||||
<CommandEmpty>{t(($) => $.issue_picker.no_results)}</CommandEmpty>
|
||||
)}
|
||||
{!isLoading && !query.trim() && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
Type to search issues
|
||||
{t(($) => $.issue_picker.prompt_to_search)}
|
||||
</div>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
FileDropOverlay,
|
||||
} from "../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
|
||||
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
|
||||
@@ -58,6 +59,7 @@ export function AgentCreatePanel({
|
||||
onSwitchMode?: () => void;
|
||||
data?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const { t } = useT("modals");
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
@@ -167,7 +169,7 @@ export function AgentCreatePanel({
|
||||
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
|
||||
setLastAgentId(agentId);
|
||||
setLastMode("agent");
|
||||
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
|
||||
toast.success(t(($) => $.create_issue.agent.toast_sent), {
|
||||
duration: 4000,
|
||||
});
|
||||
if (keepOpen) {
|
||||
@@ -195,7 +197,7 @@ export function AgentCreatePanel({
|
||||
min_version?: string;
|
||||
};
|
||||
if (body.code === "agent_unavailable") {
|
||||
setError(body.reason || "Agent is unavailable. Pick another agent.");
|
||||
setError(body.reason || t(($) => $.create_issue.agent.error_agent_unavailable_fallback));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -206,13 +208,16 @@ export function AgentCreatePanel({
|
||||
// consistency.
|
||||
const cur = body.current_version || "unknown";
|
||||
setError(
|
||||
`This agent's daemon CLI (${cur}) is below the required ${body.min_version || MIN_QUICK_CREATE_CLI_VERSION}. Upgrade the daemon to use Create with agent.`,
|
||||
t(($) => $.create_issue.agent.error_daemon_version, {
|
||||
current: cur,
|
||||
min: body.min_version || MIN_QUICK_CREATE_CLI_VERSION,
|
||||
}),
|
||||
);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError("Failed to submit. Try again.");
|
||||
setError(t(($) => $.create_issue.agent.error_unknown));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -238,14 +243,14 @@ export function AgentCreatePanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle className="sr-only">Quick create issue</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t(($) => $.create_issue.sr_agent)}</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">Create with agent</span>
|
||||
<span className="font-medium">{t(($) => $.create_issue.agent_breadcrumb)}</span>
|
||||
</div>
|
||||
{/* Native `title` instead of Base UI Tooltip — Tooltip opens on
|
||||
keyboard focus, and the dialog's focus trap briefly lands focus
|
||||
@@ -254,8 +259,8 @@ export function AgentCreatePanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
title={t(($) => $.common.close)}
|
||||
aria-label={t(($) => $.common.close)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
@@ -269,10 +274,10 @@ export function AgentCreatePanel({
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select agent"
|
||||
aria-label={t(($) => $.create_issue.agent.select_agent_aria)}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-sm px-1.5 py-1 -ml-1.5 hover:bg-accent/60"
|
||||
>
|
||||
<span>Created by</span>
|
||||
<span>{t(($) => $.create_issue.agent.created_by)}</span>
|
||||
{selectedAgent ? (
|
||||
<span className="flex items-center gap-1.5 text-foreground">
|
||||
<ActorAvatar
|
||||
@@ -283,7 +288,7 @@ export function AgentCreatePanel({
|
||||
{selectedAgent.name}
|
||||
</span>
|
||||
) : (
|
||||
<span>Pick an agent…</span>
|
||||
<span>{t(($) => $.create_issue.agent.pick_an_agent)}</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
@@ -291,7 +296,7 @@ export function AgentCreatePanel({
|
||||
<DropdownMenuContent align="start" className="w-64 max-h-72 overflow-y-auto">
|
||||
{visibleAgents.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
No agents available.
|
||||
{t(($) => $.create_issue.agent.no_agents)}
|
||||
</div>
|
||||
) : (
|
||||
visibleAgents.map((a: Agent) => (
|
||||
@@ -322,8 +327,11 @@ export function AgentCreatePanel({
|
||||
{selectedAgent && versionBlocked && (
|
||||
<div className="mx-5 mb-2 shrink-0 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
{versionCheck.state === "missing"
|
||||
? `This agent's daemon doesn't report a CLI version. Create with agent needs multica CLI ≥ ${versionCheck.min}. Upgrade the daemon and reconnect, or switch to manual create.`
|
||||
: `This agent's daemon CLI is ${versionCheck.current} — Create with agent needs ≥ ${versionCheck.min}. Upgrade the daemon, or switch to manual create.`}
|
||||
? t(($) => $.create_issue.agent.version_missing, { min: versionCheck.min })
|
||||
: t(($) => $.create_issue.agent.version_below, {
|
||||
current: versionCheck.current,
|
||||
min: versionCheck.min,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -341,7 +349,7 @@ export function AgentCreatePanel({
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={initialPrompt}
|
||||
placeholder='Tell the agent what to do, e.g. "let Bohan fix the inbox loading slowness in the Web project"'
|
||||
placeholder={t(($) => $.create_issue.agent.prompt_placeholder)}
|
||||
onUpdate={(md) => setHasContent(md.trim().length > 0)}
|
||||
onUploadFile={handleUploadFile}
|
||||
onSubmit={submit}
|
||||
@@ -364,7 +372,7 @@ export function AgentCreatePanel({
|
||||
/>
|
||||
{keepOpen && sentCount > 0 && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{sentCount} sent
|
||||
{t(($) => $.create_issue.agent.sent_count, { count: sentCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -372,11 +380,11 @@ export function AgentCreatePanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToManual}
|
||||
title="Switch to manual create — fill the fields yourself"
|
||||
title={t(($) => $.create_issue.switch_to_manual_tooltip)}
|
||||
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to Manual
|
||||
{t(($) => $.create_issue.switch_to_manual)}
|
||||
</button>
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<Switch
|
||||
@@ -384,7 +392,7 @@ export function AgentCreatePanel({
|
||||
checked={keepOpen}
|
||||
onCheckedChange={setKeepOpen}
|
||||
/>
|
||||
Create another
|
||||
{t(($) => $.create_issue.create_another)}
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -392,14 +400,14 @@ export function AgentCreatePanel({
|
||||
disabled={!hasContent || !agentId || submitting || versionBlocked || uploading}
|
||||
title={
|
||||
versionBlocked
|
||||
? `Daemon CLI must be ≥ ${versionCheck.min}`
|
||||
? t(($) => $.create_issue.agent.version_blocked_tooltip, { min: versionCheck.min })
|
||||
: undefined
|
||||
}
|
||||
className={justSent ? "min-w-28 !bg-emerald-600 !text-white" : "min-w-28"}
|
||||
>
|
||||
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (
|
||||
<span className="flex items-center gap-1"><Check className="size-3.5" />Sent</span>
|
||||
) : "Create (⌘↵)"}
|
||||
{submitting ? t(($) => $.create_issue.agent.sending) : uploading ? t(($) => $.create_issue.agent.uploading) : justSent ? (
|
||||
<span className="flex items-center gap-1"><Check className="size-3.5" />{t(($) => $.create_issue.agent.sent_label)}</span>
|
||||
) : t(($) => $.create_issue.agent.submit)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { childIssuesOptions } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { IssuePickerModal } from "./issue-picker-modal";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export function SetParentIssueModal({
|
||||
onClose,
|
||||
@@ -14,6 +15,7 @@ export function SetParentIssueModal({
|
||||
onClose: () => void;
|
||||
data: Record<string, unknown> | null;
|
||||
}) {
|
||||
const { t } = useT("modals");
|
||||
const issueId = (data?.issueId as string) || "";
|
||||
const wsId = useWorkspaceId();
|
||||
const updateIssue = useUpdateIssue();
|
||||
@@ -31,15 +33,15 @@ export function SetParentIssueModal({
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose();
|
||||
}}
|
||||
title="Set parent issue"
|
||||
description="Search for an issue to set as the parent of this issue"
|
||||
title={t(($) => $.set_parent.title)}
|
||||
description={t(($) => $.set_parent.description)}
|
||||
excludeIds={excludeIds}
|
||||
onSelect={(selected) => {
|
||||
updateIssue.mutate(
|
||||
{ id: issueId, parent_issue_id: selected.id },
|
||||
{ onError: () => toast.error("Failed to update issue") },
|
||||
{ onError: () => toast.error(t(($) => $.set_parent.toast_failed)) },
|
||||
);
|
||||
toast.success(`Set ${selected.identifier} as parent issue`);
|
||||
toast.success(t(($) => $.set_parent.toast_success, { identifier: selected.identifier }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user