From d4d76012652ea6cd043558f715bd5f0670f7d51a Mon Sep 17 00:00:00 2001 From: Bindy-lbb <70745012+Bindy-lbb@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:40:17 +0800 Subject: [PATCH] fix(goals): repair acceptance criteria and live task navigation (#578, #579, #580) --- CHANGELOG.md | 2 + .../renderer/src/goals/GoalCriteriaDialog.tsx | 78 ++++++++ .../renderer/src/goals/GoalsModule.tsx | 69 ++++--- .../test/goals-client-followup.test.tsx | 187 ++++++++++++++++++ .../renderer/test/vault-module.test.tsx | 14 ++ packages/ui/src/locales/en.ts | 11 ++ packages/ui/src/locales/zh.ts | 11 ++ 7 files changed, 348 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/renderer/src/goals/GoalCriteriaDialog.tsx create mode 100644 apps/desktop/renderer/test/goals-client-followup.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a7eb1a4e..a9f6059f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ ### Fixed +- #578 / #579 / #580:目标与项目支持补充或编辑验收标准,冲突保留草稿并可加载最新标准;阻塞目标只跳转明确绑定的会话;Agent 看板随任务事件和重连刷新,丢弃过期响应,加载失败保留快照并可重试。 + - 桌面标题栏按窗口所在系统显示控制钮:Windows 使用右侧原生最小化、最大化/还原与关闭,macOS 保留左侧红黄绿;WSL 后端不影响 Windows 窗口样式,原生控制区同步明暗与自定义主题。 - 项目看板派工复用员工活动会话,结果按原 session/turn 回看;旋转会话与控制面重启后仍可定位,旧版无 session 结果提供独立只读入口。 - 员工执行期间可调整下一轮模型,当前执行保持启动时的模型快照;Qoder 活动轨迹展示公开工具参数和完成详情。 diff --git a/apps/desktop/renderer/src/goals/GoalCriteriaDialog.tsx b/apps/desktop/renderer/src/goals/GoalCriteriaDialog.tsx new file mode 100644 index 00000000..5b5a36bd --- /dev/null +++ b/apps/desktop/renderer/src/goals/GoalCriteriaDialog.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from "react"; +import { Alert, Button, Input, Modal } from "antd"; +import { useT } from "@roleweave/ui"; +import { validateGoalUpdateRequest, type Goal } from "@roleweave/shared/goals"; + +interface GoalCriteriaDialogProps { + goal: Goal; + onClose: () => void; + onSaved: () => Promise; +} + +/** Keep the reviewed criteria and their version together until explicitly reloaded. */ +export function GoalCriteriaDialog({ goal, onClose, onSaved }: GoalCriteriaDialogProps) { + const t = useT(); + const [criteria, setCriteria] = useState([...goal.acceptanceCriteria]); + const [version, setVersion] = useState(goal.updatedAt); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [conflict, setConflict] = useState(false); + const alive = useRef(true); + const lock = useRef(false); + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + const nextCriteria = criteria.map((item) => item.trim()).filter(Boolean); + const valid = nextCriteria.length > 0 && validateGoalUpdateRequest({ acceptanceCriteria: nextCriteria, expectedUpdatedAt: version }).ok; + + const reload = async () => { + if (lock.current) return; + lock.current = true; setBusy(true); setError(null); + try { + const response = await window.owb.goal(goal.goalId); + if (!alive.current) return; + if (response.status !== 200) throw new Error(t("goals.loadError")); + setCriteria([...response.body.goal.acceptanceCriteria]); + setVersion(response.body.goal.updatedAt); + setConflict(false); + } catch { + if (alive.current) setError(t("goals.loadError")); + } finally { + lock.current = false; + if (alive.current) setBusy(false); + } + }; + const save = async () => { + if (lock.current || !valid || conflict) return; + lock.current = true; setBusy(true); setError(null); + try { + const response = await window.owb.updateGoal({ goalId: goal.goalId, acceptanceCriteria: nextCriteria, expectedUpdatedAt: version }); + if (!alive.current) return; + if (response.status !== 200) { + setConflict(response.status === 409); + setError(t(response.status === 409 ? "goals.criteriaConflict" : "goals.criteriaSaveError")); + return; + } + await onSaved(); + if (alive.current) onClose(); + } catch { + if (alive.current) setError(t("goals.criteriaSaveError")); + } finally { + lock.current = false; + if (alive.current) setBusy(false); + } + }; + return { if (!busy) onClose(); }} + mask={{ closable: !busy }} closable={!busy} onOk={() => void save()} confirmLoading={busy} + okButtonProps={{ disabled: !valid || conflict }} okText={t("goals.criteriaSave")} cancelText={t("dlg.cancel")}> +

{t("goals.criteriaEditHint")}

+ {error && } + {conflict && <>

{t("goals.criteriaReloadHint")}

} + {criteria.map((criterion, index) =>
+ setCriteria((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} /> + +
)} + +
; +} diff --git a/apps/desktop/renderer/src/goals/GoalsModule.tsx b/apps/desktop/renderer/src/goals/GoalsModule.tsx index 9c25a8d7..a31bd5e0 100644 --- a/apps/desktop/renderer/src/goals/GoalsModule.tsx +++ b/apps/desktop/renderer/src/goals/GoalsModule.tsx @@ -17,6 +17,7 @@ import "./goals-project.css"; import "./goals-workspace.css"; import { GoalEvidence } from "./GoalEvidence.js"; import { GoalCreateDialog } from "./GoalCreateDialog.js"; +import { GoalCriteriaDialog } from "./GoalCriteriaDialog.js"; interface GoalsModuleProps { workspaceOpen: boolean; @@ -69,6 +70,8 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p const ActivitySection = projectMode ? "section" : "details"; const [view, setView] = useState<"goals" | "board">("goals"); const [tasks, setTasks] = useState([]); + const [taskError, setTaskError] = useState(null); + const [tasksLoading, setTasksLoading] = useState(false); const [showTaskCreate, setShowTaskCreate] = useState(false); const [taskTitle, setTaskTitle] = useState(""); const [taskTarget, setTaskTarget] = useState(""); @@ -85,6 +88,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p const [detailError, setDetailError] = useState(null); const [actionError, setActionError] = useState(null); const [showCreate, setShowCreate] = useState(false); + const [criteriaGoalId, setCriteriaGoalId] = useState(null); const [mutating, setMutating] = useState(false); const mutationLock = useRef(false); const [query, setQuery] = useState(""); @@ -93,6 +97,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p const alive = useRef(true); const listVersion = useRef(0); const detailVersion = useRef(0); + const tasksVersion = useRef(0); useEffect(() => { alive.current = true; @@ -100,6 +105,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p alive.current = false; listVersion.current += 1; detailVersion.current += 1; + tasksVersion.current += 1; }; }, []); @@ -110,6 +116,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p setDetail(null); setDetailError(null); setActionError(null); + setCriteriaGoalId(null); } selectedRef.current = id; setSelectedId(id); @@ -186,19 +193,31 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p useEffect(() => { void loadGoals(); }, [loadGoals]); + const reloadTasks = useCallback(async () => { + if (!workspaceOpen || typeof window.owb.tasks !== "function") return; + const version = ++tasksVersion.current; + const isCurrent = () => alive.current && version === tasksVersion.current; + setTasksLoading(true); + setTaskError(null); + try { + const response = await window.owb.tasks(); + if (!isCurrent()) return; + if (response.status !== 200) throw new Error(errorMessage(response.body, t("tasks.loadError"))); + setTasks(response.body.tasks); + } catch (cause) { + if (isCurrent()) setTaskError(cause instanceof Error ? cause.message : t("tasks.loadError")); + } finally { + if (isCurrent()) setTasksLoading(false); + } + }, [workspaceOpen, t]); useEffect(() => { - if (!workspaceOpen || view !== "board" || typeof window.owb.tasks !== "function") return; - let cancelled = false; - void window.owb.tasks().then((response) => { - if (!cancelled && response.status === 200) setTasks(response.body.tasks); + if (view !== "board") return; + void reloadTasks(); + const off = window.owb.onSseStatus?.((state) => { + if (state === "connected") void reloadTasks(); }); - return () => { cancelled = true; }; - }, [workspaceOpen, view]); - const reloadTasks = useCallback(async () => { - if (typeof window.owb.tasks !== "function") return; - const response = await window.owb.tasks(); - if (response.status === 200) setTasks(response.body.tasks); - }, []); + return () => { tasksVersion.current += 1; off?.(); }; + }, [view, reloadTasks]); const createTask = useCallback(async () => { const owner = ownerPositionId; if (!owner || !taskTarget || !taskTitle.trim()) return; @@ -226,9 +245,10 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p ) { void loadGoals(); if (selectedRef.current) void loadDetail(selectedRef.current); + if (view === "board") void reloadTasks(); } }); - }, [workspaceOpen, loadGoals, loadDetail]); + }, [workspaceOpen, loadGoals, loadDetail, view, reloadTasks]); // Refresh execution evidence without unmounting an open task editor. useEffect(() => { @@ -318,6 +338,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p setQuery(""); setStatusFilter("all"); }; + const boundBranch = detail?.goal.branches.find((branch) => branch.positionId && branch.sessionId); const empty = !loading && !error && goals.length === 0; const createButton = ( ) : !projectMode && view === "board" ? (
+ {taskError &&
{taskError}{" "} void reloadTasks()}>{t("hire.retry")}
} + {tasksLoading &&

{t("tasks.loading")}

} {(["queued", "active", "waiting", "done"] as const).map((column) => (
{t(`tasks.column.${column}`)}{tasks.filter((task) => column === "done" ? ["done", "declined", "failed"].includes(task.status) : task.status === column).length}
@@ -616,18 +639,11 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p
{detail.healthOverlay === "blocked" ? (
- {detail.goal.branches.some((branch) => branch.positionId) ? ( + {boundBranch && onOpenBoundSession ? ( { - const branch = detail.goal.branches.find( - (item) => item.positionId, - ); - if (branch?.positionId) - onOpenBoundSession?.( - branch.positionId, - branch.sessionId, - ); + onOpenBoundSession(boundBranch.positionId!, boundBranch.sessionId!); }} > {t("goals.health.openBoundTurn")} @@ -641,7 +657,7 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p {t("goals.health.openApprovals")} ) : null} - {!detail.goal.branches.some((branch) => branch.positionId) && + {!(boundBranch && onOpenBoundSession) && !onOpenApprovals ? (

{t("goals.health.blockedNoTarget")} @@ -674,9 +690,9 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p {detail.goal.description}

- {detail.goal.acceptanceCriteria.length > 0 && (

{t(projectMode ? "project.form.criteria" : "goals.criteria")}

+ setCriteriaGoalId(detail.goal.goalId)}>{t("goals.criteriaEdit")}
    {detail.goal.acceptanceCriteria.map( (criterion, index) => ( @@ -685,7 +701,6 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p )}
- )} {!projectMode && } {projectMode && detail.goal.branches.length > 0 && (
@@ -739,6 +754,12 @@ function GoalsWorkspace({ workspaceOpen, workspaceKey, presentation = "goals", p void loadGoals(id); }} /> + {detail && criteriaGoalId === detail.goal.goalId && setCriteriaGoalId(null)} + onSaved={async () => { await Promise.all([loadGoals(), loadDetail(detail.goal.goalId)]); }} + />} )}
diff --git a/apps/desktop/renderer/test/goals-client-followup.test.tsx b/apps/desktop/renderer/test/goals-client-followup.test.tsx new file mode 100644 index 00000000..283ee874 --- /dev/null +++ b/apps/desktop/renderer/test/goals-client-followup.test.tsx @@ -0,0 +1,187 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { OwbI18nProvider } from "@roleweave/ui"; +import type { AgentTask, GoalDetail, GoalTaskAcceptanceRecord } from "@roleweave/shared"; +import type { OwbBridge } from "../src/owb"; +import { GoalsModule } from "../src/goals/GoalsModule"; +import { GoalCriteriaDialog } from "../src/goals/GoalCriteriaDialog"; + +const at = "2026-10-01T00:00:00.000Z"; +const later = "2026-10-02T00:00:00.000Z"; +function detail(): GoalDetail { + return { goal: { schemaVersion: "goal.v1", goalId: "followup-goal", title: "Review project", description: "Project description", + acceptanceCriteria: [], status: "open", health: "unknown", branches: [], createdAt: at, updatedAt: at }, activity: [] }; +} +function install(value = detail(), overrides: Partial = {}) { + const bridge = { + goals: vi.fn().mockResolvedValue({ status: 200, body: { goals: [{ ...value.goal, branchCount: value.goal.branches.length }] } }), + goal: vi.fn().mockResolvedValue({ status: 200, body: value }), + onEvent: vi.fn().mockReturnValue(() => {}), + ...overrides, + }; + window.owb = bridge as unknown as OwbBridge; + return bridge; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function branch(sessionId?: string) { + return { branchId: `branch-${sessionId ?? "unbound"}`, title: `Branch ${sessionId ?? "unbound"}`, positionId: "engineer", + sessionId, status: "in_progress" as const, createdAt: at, updatedAt: at }; +} + +describe("blocked goal navigation (#579)", () => { + it.each([undefined, ""])("hides a shortcut for an unbound branch (%s)", async (sessionId) => { + const value = detail(); value.goal.branches = [branch(sessionId)]; value.healthOverlay = "blocked"; + install(value); + render(); + await screen.findByText(value.goal.branches[0].title.trim()); + expect(screen.queryByTestId("goals-health-open-turn")).not.toBeInTheDocument(); + expect(screen.getByTestId("goals-health-blocked-empty")).toBeInTheDocument(); + }); + it("skips role-only branches and opens the exact later bound session", async () => { + const value = detail(); value.goal.branches = [branch(), branch("bound-session")]; value.healthOverlay = "blocked"; + install(value); + const open = vi.fn(); + render(); + fireEvent.click(await screen.findByTestId("goals-health-open-turn")); + expect(open).toHaveBeenCalledWith("engineer", "bound-session"); + }); + it("hides a nonfunctional shortcut when no session callback is provided", async () => { + const value = detail(); value.goal.branches = [branch("bound-session")]; value.healthOverlay = "blocked"; + install(value); + render(); + await screen.findByText("Branch bound-session"); + expect(screen.queryByTestId("goals-health-open-turn")).not.toBeInTheDocument(); + expect(screen.getByTestId("goals-health-blocked-empty")).toBeInTheDocument(); + }); +}); + +const task = (title: string): AgentTask => ({ schemaVersion: "task-board.v1", taskId: title, title, description: "", status: "queued", + assigneePositionId: "engineer", requestedByPositionId: "owner", budgetOwnerPositionId: "owner", kind: "direct", mainline: true, + priority: "normal", createdAt: at, updatedAt: at }); +describe("live task board (#580)", () => { + it("refreshes after task events and reconnect, ignoring older responses", async () => { + const old = deferred<{ status: number; body: { tasks: AgentTask[] } }>(); + const tasks = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue({ status: 200, body: { tasks: [task("Newest task")] } }); + let event: (value: unknown) => void = () => {}; + let sse: (value: "connected" | "connecting") => void = () => {}; + install(detail(), { tasks, onEvent: vi.fn((fn) => { event = fn; return () => {}; }), onSseStatus: vi.fn((fn) => { sse = fn; return () => {}; }) }); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Agent 看板" })); + await waitFor(() => expect(tasks).toHaveBeenCalledTimes(1)); + expect(screen.getByRole("status")).toHaveTextContent("正在读取任务…"); + await act(async () => event({ type: "goal.updated", taskId: "Newest task" })); + await screen.findByText("Newest task"); + await act(async () => old.resolve({ status: 200, body: { tasks: [task("Stale task")] } })); + expect(screen.queryByText("Stale task")).not.toBeInTheDocument(); + await act(async () => sse("connected")); + expect(tasks).toHaveBeenCalledTimes(3); + }); + it("preserves the last snapshot after failure and lets the user retry", async () => { + const tasks = vi.fn().mockResolvedValueOnce({ status: 200, body: { tasks: [task("Cached task")] } }) + .mockRejectedValueOnce(new Error("Offline")) + .mockResolvedValue({ status: 200, body: { tasks: [task("Recovered task")] } }); + let event: (value: unknown) => void = () => {}; + install(detail(), { tasks, onEvent: vi.fn((fn) => { event = fn; return () => {}; }) }); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Agent 看板" })); + await screen.findByText("Cached task"); + await act(async () => event({ type: "goal.updated", taskId: "Cached task" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Offline"); + expect(screen.getByText("Cached task")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /重\s*试/ })); + await screen.findByText("Recovered task"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("discards requests from a previous workspace with identical task identities", async () => { + const old = deferred<{ status: number; body: { tasks: AgentTask[] } }>(); + install(detail(), { tasks: vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue({ status: 200, body: { tasks: [task("Workspace B task")] } }) }); + const view = render(); + fireEvent.click(screen.getByRole("tab", { name: "Agent 看板" })); + view.rerender(); + fireEvent.click(screen.getByRole("tab", { name: "Agent 看板" })); + await screen.findByText("Workspace B task"); + await act(async () => old.resolve({ status: 200, body: { tasks: [task("Workspace A task")] } })); + expect(screen.queryByText("Workspace A task")).not.toBeInTheDocument(); + }); +}); + +describe("acceptance criteria repair (#578)", () => { + it("preserves a conflicting draft, explicitly reloads current criteria and retries with the new version", async () => { + const latest = detail(); latest.goal.acceptanceCriteria = ["Server criterion"]; latest.goal.updatedAt = later; + const updateGoal = vi.fn().mockResolvedValueOnce({ status: 409, body: {} }).mockResolvedValue({ status: 200, body: {} }); + install(latest, { updateGoal }); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "添加标准" })); + fireEvent.change(screen.getByLabelText("验收标准 1"), { target: { value: "My draft" } }); + fireEvent.click(screen.getByRole("button", { name: "保存验收标准" })); + await screen.findByRole("alert"); + expect(screen.getByLabelText("验收标准 1")).toHaveValue("My draft"); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "加载最新验收标准" })); + await waitFor(() => expect(screen.getByLabelText("验收标准 1")).toHaveValue("Server criterion")); + fireEvent.change(screen.getByLabelText("验收标准 1"), { target: { value: "Reviewed latest criterion" } }); + fireEvent.click(screen.getByRole("button", { name: "保存验收标准" })); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + expect(updateGoal).toHaveBeenLastCalledWith({ goalId: "followup-goal", acceptanceCriteria: ["Reviewed latest criterion"], expectedUpdatedAt: later }); + }); + it("keeps an unsuccessful save retryable, locks duplicate submissions and supports English", async () => { + const pending = deferred<{ status: number; body: object }>(); + const updateGoal = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue({ status: 200, body: {} }); + install(detail(), { updateGoal }); + const onClose = vi.fn(); + render(); + expect(screen.getByRole("button", { name: "Save acceptance criteria" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Add criterion" })); + fireEvent.change(screen.getByLabelText("Acceptance criterion 1"), { target: { value: " Build works " } }); + const save = screen.getByRole("button", { name: "Save acceptance criteria" }); + fireEvent.click(save); fireEvent.click(save); + expect(updateGoal).toHaveBeenCalledTimes(1); + await act(async () => pending.resolve({ status: 500, body: {} })); + expect(await screen.findByRole("alert")).toHaveTextContent("Your draft is preserved"); + fireEvent.click(save); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + expect(updateGoal).toHaveBeenLastCalledWith({ goalId: "followup-goal", acceptanceCriteria: ["Build works"], expectedUpdatedAt: at }); + }); + it("lets a project with no criteria add a criterion and accept its real delivery", async () => { + const value = detail(); + const item = { taskId: "delivery-task", title: "Delivery", priority: "normal" as const, status: "review" as const, assigneePositionId: "engineer" }; + const source = { positionId: "engineer", turnId: "delivery-turn", sessionId: "delivery-session", outputDigest: `sha256:${"a".repeat(64)}` }; + value.goal.workItems = [item]; + value.taskExecutions = { [item.taskId]: { ...source, status: "completed" } }; + value.taskDeliveries = { [item.taskId]: { source, output: "Delivered implementation", completedAt: at } }; + const updateGoal = vi.fn(async (request) => { + value.goal.acceptanceCriteria = request.acceptanceCriteria; value.goal.updatedAt = later; + return { status: 200, body: { goalId: value.goal.goalId } }; + }); + const createGoalTaskAcceptance = vi.fn(async (request) => { + const record: GoalTaskAcceptanceRecord = { schemaVersion: "goal-task-acceptance.v1", scope: "goal-task", acceptanceId: "accepted", + goalId: value.goal.goalId, taskId: item.taskId, planUpdatedAt: later, workspacePath: "/workspace", criteriaSnapshot: ["Build works"], source, + decision: "accepted", verdicts: request.verdicts, decidedBy: "operator", decidedAt: later, + idempotencyKey: request.idempotencyKey, requestDigest: `sha256:${"b".repeat(64)}` }; + value.goal.workItems = [{ ...item, status: "done" }]; value.goal.taskAcceptances = [record]; + return { status: 201, body: { record } }; + }); + install(value, { updateGoal: updateGoal as OwbBridge["updateGoal"], createGoalTaskAcceptance: createGoalTaskAcceptance as OwbBridge["createGoalTaskAcceptance"] }); + render(); + await screen.findByRole("button", { name: "Delivery", exact: true }); + fireEvent.click(document.querySelector(".owb-project-overview > summary")!); + fireEvent.click(screen.getByRole("button", { name: "编辑验收标准" })); + fireEvent.click(screen.getByRole("button", { name: "添加标准" })); + fireEvent.change(screen.getByLabelText("验收标准 1"), { target: { value: "Build works" } }); + fireEvent.click(screen.getByRole("button", { name: "保存验收标准" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "验收交付:Delivery" })); + const drawer = within(await screen.findByRole("dialog")); + expect(drawer.getByLabelText("Agent 真实产出")).toHaveTextContent("Delivered implementation"); + fireEvent.click(drawer.getByRole("checkbox", { name: "Build works" })); + fireEvent.click(drawer.getByRole("button", { name: "验收通过" })); + await screen.findByText("验收已保存,任务已完成。"); + expect(createGoalTaskAcceptance).toHaveBeenCalledWith(expect.objectContaining({ source, expectedUpdatedAt: later, decision: "accepted", verdicts: [{ criteriaIndex: 0, passed: true }] })); + expect(value.goal.workItems[0].status).toBe("done"); + }); +}); diff --git a/apps/desktop/renderer/test/vault-module.test.tsx b/apps/desktop/renderer/test/vault-module.test.tsx index 3be866fd..f7475369 100644 --- a/apps/desktop/renderer/test/vault-module.test.tsx +++ b/apps/desktop/renderer/test/vault-module.test.tsx @@ -191,6 +191,20 @@ describe("统一笔记库", () => { expect(screen.queryByText("A 的未保存内容")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "同步笔记库" })).toBeEnabled(); }); + it("isolates unsaved drafts across workspace paths even with the same vault, note path and version", async () => { + const first = render(); + await screen.findByRole("heading", { name: "项目约定" }); + fireEvent.click(screen.getByRole("button", { name: "更多文档操作" })); + fireEvent.click(await screen.findByRole("menuitem", { name: /源.*码/ })); + fireEvent.change(screen.getByRole("textbox", { name: "文档内容" }), { target: { value: "# Workspace A private draft" } }); + first.unmount(); + render(); + expect(await screen.findByRole("heading", { name: "项目约定" })).toBeVisible(); + expect(screen.queryByText("Workspace A private draft")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "同步笔记库" })).toBeEnabled(); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 650)); }); + expect(vault.write).not.toHaveBeenCalled(); + }); it("reports sync conflicts without overwriting or dismissing the local note", async () => { vault.sync.mockResolvedValue(response({ status: "conflict", pushed: 1, pulled: 1, conflicts: [{ path: "项目约定.md", copyPath: "项目约定-conflict.md" }] })); render(); diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts index 4c21389c..36d918bb 100644 --- a/packages/ui/src/locales/en.ts +++ b/packages/ui/src/locales/en.ts @@ -2,6 +2,17 @@ * test; keep the two files in lockstep. */ import { enVaultCatalog } from "./vault"; export const enCatalog: Record = { + "tasks.loadError": "The task board could not be loaded. The last successful task snapshot is preserved.", + "tasks.loading": "Loading tasks…", + "goals.criteriaEdit": "Edit acceptance criteria", + "goals.criteriaSave": "Save acceptance criteria", + "goals.criteriaEditHint": "Add 1–16 acceptance criteria. After a change, review existing deliveries against the new criteria.", + "goals.criteriaItem": "Acceptance criterion {index}", + "goals.criteriaRemoveItem": "Remove acceptance criterion {index}", + "goals.criteriaSaveError": "Acceptance criteria could not be saved. Your draft is preserved; try again.", + "goals.criteriaConflict": "The goal has changed. Your draft is preserved. Load and review the latest criteria before saving.", + "goals.criteriaReload": "Load latest acceptance criteria", + "goals.criteriaReloadHint": "Loading the latest criteria replaces your current draft.", ...enVaultCatalog, "project.legacyResult": "Legacy execution result", "project.legacyResultReadOnly": "This execution has no personal session and is read-only. Run the task again to review and accept a session-bound delivery.", diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts index d48a503e..afd8c712 100644 --- a/packages/ui/src/locales/zh.ts +++ b/packages/ui/src/locales/zh.ts @@ -5,6 +5,17 @@ * 插值用 {var}。 */ import { zhVaultCatalog } from "./vault"; export const zhCatalog: Record = { + "tasks.loadError": "任务看板加载失败,已保留上次成功加载的任务。", + "tasks.loading": "正在读取任务…", + "goals.criteriaEdit": "编辑验收标准", + "goals.criteriaSave": "保存验收标准", + "goals.criteriaEditHint": "至少填写一项验收标准,最多 16 项。修改后需按新标准重新核对已有交付。", + "goals.criteriaItem": "验收标准 {index}", + "goals.criteriaRemoveItem": "移除验收标准 {index}", + "goals.criteriaSaveError": "验收标准保存失败,草稿已保留,请重试。", + "goals.criteriaConflict": "目标已变化,草稿已保留。请加载最新标准并核对后再保存。", + "goals.criteriaReload": "加载最新验收标准", + "goals.criteriaReloadHint": "加载最新标准会替换当前编辑草稿。", ...zhVaultCatalog, "project.legacyResult": "旧版执行结果", "project.legacyResultReadOnly": "该执行没有真实会话关联,仅可查看。请重新执行任务,再验收新会话中的交付结果。",