diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb346ad..0dd373e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added - #615: Show per-task execution progress in project cards, schedule rows, and the task editor. Reuse live stage events and exact persisted snapshots, recover on reconnect, and keep completed execution separate from delivery acceptance. +- #466:审批队列和详情展示可选 Jev 老化建议(`nudge` / `wait` / `discuss-close`),仅为文案。出站载荷只有 `{ createdAt, now, decision.kind, risk }`。overlay 不得隐藏 pending high,不得 POST decision,点击只打开事项。过期仍走现有 clock helpers。`ROLEWEAVE_LAYA_ENABLED` 默认关。https://github.com/bytefolk/roleweave/issues/422 ### Fixed @@ -72,6 +73,7 @@ - #559:新增协作区人工验收契约,验收通过必须完整覆盖计划中的全部验收项;限制标识符、时间戳和产物路径,拒绝遗漏或越界的裁决。 - #469:可选 Laya 接力停止建议。仅在 `ROLEWEAVE_LAYA_ENABLED` 开启、已完成步骤得到正向建议且仍有未执行步骤时暂停;桌面端由 owner 显式选择“停止剩余步骤”或“继续接力”。30 秒超时、SSE 断连、无效或失败建议均继续原 mention order;Laya 只接收 `status`、`errorCode`、`hasOutput`,不接收输入、输出或 handoff 文本。已接受的消息与 spawn 清单保持不可变。Refs #422。 +>>>>>>> origin/main - #360:工作区 `work/` 领地布局。initialize/create 脚手架 `work/README.md`(已有工作区不加强制迁移)。hire 创建 `work//`,仅给 hire 岗位生成 SKILL.md Territory 段,并把新角色的 `memoryScope` 写成 `./work//`;项目负责人保持 `memoryScope: "/"`,SKILL 不声称不存在的 `work//`。默认 `toolAllow` 只有 Read/Grep/Glob,不含 Write/Edit/Bash。hire 任何 staging 失败都回滚声明文件。employee.json 摘要密封机制不变。Refs #360。 diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index 81a8ba00..d3e7c4fc 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -77,6 +77,7 @@ const SERVER_RUNTIME_FILES = [ "src/windows-launcher.js", "dist/src/agent-binding.js", "dist/src/agent-registry.js", + "dist/src/approvals/aging-overlay.js", "dist/src/approvals/context.js", "dist/src/approvals/delivery.js", "dist/src/approvals/policy.js", diff --git a/apps/desktop/renderer/src/approvals/ApprovalDetail.tsx b/apps/desktop/renderer/src/approvals/ApprovalDetail.tsx index 754f5fce..7b4a782b 100644 --- a/apps/desktop/renderer/src/approvals/ApprovalDetail.tsx +++ b/apps/desktop/renderer/src/approvals/ApprovalDetail.tsx @@ -116,6 +116,11 @@ function SelectedApprovalDetail({ {t("apr.riskOverlay")}: {t(`apr.risk.${context.riskOverlay}`)} · {t("apr.suggestionNotAdopted")} ) : null} + {context.agingOverlay ? ( + + {t("apr.agingOverlay")}: {t(`apr.aging.${context.agingOverlay}`)} + + ) : null} ) : null} {permissionMode ? ( diff --git a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx index 3a4e3a28..350a8666 100644 --- a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx +++ b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx @@ -666,6 +666,11 @@ function ApprovalCard({ item, now, onOpen, selected, batchSelected, batchDisable {t("apr.riskOverlay")}: {t(`apr.risk.${item.context.riskOverlay}`)} · {t("apr.suggestionNotAdopted")} ) : null} + {item.context?.agingOverlay ? ( + + {t("apr.agingOverlay")}: {t(`apr.aging.${item.context.agingOverlay}`)} + + ) : null} {overreach ? ( {t("apr.overreach")} diff --git a/apps/desktop/renderer/test/approval-queue.test.tsx b/apps/desktop/renderer/test/approval-queue.test.tsx index 681b4444..ed86d4ca 100644 --- a/apps/desktop/renderer/test/approval-queue.test.tsx +++ b/apps/desktop/renderer/test/approval-queue.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import { enterPickerDate } from "./date-picker-helper"; import { describe, expect, it, vi } from "vitest"; import { ApprovalQueue } from "../src/approvals/ApprovalQueue"; -import type { ApprovalQueueItem } from "../src/approvals/types"; +import { approvalExpiryState, isActionablePending, type ApprovalQueueItem } from "../src/approvals/types"; import { pickSelectOption } from "./select-helper"; function makeItem(over: Partial = {}): ApprovalQueueItem { @@ -178,6 +178,68 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => { expect(within(screen.getByTestId("approval-card-appr-abc")).getByTestId("approval-risk-overlay").textContent ?? "").toMatch(/建议未采纳|suggestion not adopted|apr.suggestionNotAdopted/); }); + it("aging overlay cannot hide pending high and click opens the item without posting a decision", () => { + const onApprove = vi.fn(); + const onDeny = vi.fn(); + const highPending = makeItem({ + approvalId: "high-pending", + category: "exec", + context: { + risk: "high", + agingOverlay: "wait", + requestedCapability: "exec", + impact: "command_execution", + permissions: { mode: "approval_required", allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable", reason: "engine_preview_not_supplied" }, + scope: { allowed: ["once"] }, + }, + }); + const mediumPending = makeItem({ + approvalId: "medium-pending", + category: "tool", + description: "restricted tool", + context: { + risk: "medium", + agingOverlay: "nudge", + requestedCapability: "tool", + impact: "restricted_tool", + permissions: { mode: "approval_required", allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable", reason: "engine_preview_not_supplied" }, + scope: { allowed: ["once"] }, + }, + }); + render(); + const highCard = screen.getByTestId("approval-card-high-pending"); + expect(highCard).toBeInTheDocument(); + expect(within(highCard).getByTestId("approval-aging-overlay")).toHaveTextContent(/继续等待|wait/i); + fireEvent.click(within(highCard).getByTestId("approval-aging-overlay")); + const drawer = screen.getByTestId("approval-detail"); + expect(drawer).toBeInTheDocument(); + expect(drawer.getAttribute("data-approval-id")).toBe("high-pending"); + expect(drawer.getAttribute("data-approval-id")).not.toBe("medium-pending"); + expect(onApprove).not.toHaveBeenCalled(); + expect(onDeny).not.toHaveBeenCalled(); + }); + + it("expired vs pending still use existing clock helpers when aging overlay is present", () => { + const now = Date.parse("2026-08-27T14:00:00.000Z"); + const context = { + risk: "high" as const, + agingOverlay: "nudge" as const, + requestedCapability: "exec" as const, + impact: "command_execution" as const, + permissions: { mode: "approval_required" as const, allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable" as const, reason: "engine_preview_not_supplied" as const }, + scope: { allowed: ["once" as const] }, + }; + const pending = makeItem({ expiresAt: "2026-08-28T12:00:00.000Z", context }); + const expired = makeItem({ approvalId: "expired", expiresAt: "2026-08-27T13:00:00.000Z", context }); + expect(approvalExpiryState(pending, now)).toBe("expiring"); + expect(isActionablePending(pending, now)).toBe(true); + expect(approvalExpiryState(expired, now)).toBe("expired"); + expect(isActionablePending(expired, now)).toBe(false); + }); + it("\u6e32\u67d3 pending \u5217\u8868\uff1a\u5c97\u4f4d\u540d\u3001\u63cf\u8ff0\u3001\u76ee\u6807\u5747\u5230\u4f4d", () => { render( , diff --git a/apps/server/src/approvals/aging-overlay.ts b/apps/server/src/approvals/aging-overlay.ts new file mode 100644 index 00000000..90014d93 --- /dev/null +++ b/apps/server/src/approvals/aging-overlay.ts @@ -0,0 +1,179 @@ +import type { ApprovalContext, ApprovalRiskLevel, ApprovalStatus } from "@roleweave/shared"; +import { askLaya, type LayaAsk } from "../laya/client.js"; +import { layaEnabled } from "../laya/config.js"; + +export type AgingChoice = "nudge" | "wait" | "discuss-close"; + +export interface AgingQueueRow { + id: string; + createdAt: string; + now: number; + decision: { kind: ApprovalStatus | string }; + risk: ApprovalRiskLevel; +} + +export interface AgingSuggestion { + choice: AgingChoice; + needsAttention: boolean; +} + +const AGING_CHOICES = new Set(["nudge", "wait", "discuss-close"]); + +function ruleRiskLayer(risk: ApprovalRiskLevel): number { + return risk === "high" ? 0 : 1; +} + +export function agingNeedsAttention(choice: AgingChoice): boolean { + return choice === "nudge"; +} + +/** Deterministic age from the injected clock. Expiry stays on existing helpers. */ +export function agingAgeMs(createdAt: string, now: number): number | undefined { + const created = Date.parse(createdAt); + if (!Number.isFinite(created) || !Number.isFinite(now)) return undefined; + return Math.max(0, now - created); +} + +/** Overlay may reorder inside a rule-risk layer. It must not drop pending high. + * With no overlay, the original order is preserved so flag-off lists stay byte-stable. */ +export function presentAgingQueue( + items: readonly T[], + overlays: Readonly>, +): T[] { + const hasOverlay = items.some((item) => overlays[item.id] !== undefined); + if (!hasOverlay) return [...items]; + return items + .map((item, index) => ({ item, index })) + .sort((a, b) => { + const layer = ruleRiskLayer(a.item.risk) - ruleRiskLayer(b.item.risk); + if (layer !== 0) return layer; + const aAttention = overlays[a.item.id]?.needsAttention === true ? 0 : 1; + const bAttention = overlays[b.item.id]?.needsAttention === true ? 0 : 1; + if (aAttention !== bAttention) return aAttention - bAttention; + return a.index - b.index; + }) + .map((entry) => entry.item); +} + +export function rankApprovalViews(views: readonly T[], now: number): T[] { + const overlays: Record = {}; + const rows = views.map((view) => { + const choice = view.context?.agingOverlay; + if (choice) overlays[view.id] = { choice, needsAttention: agingNeedsAttention(choice) }; + return { + id: view.id, + createdAt: view.createdAt, + now, + decision: { kind: view.status }, + risk: (view.context?.risk === "high" ? "high" : "medium") as ApprovalRiskLevel, + view, + }; + }); + return presentAgingQueue(rows, overlays).map((row) => row.view); +} + +/** Kind-and-clock payload. Description, target, and turn bodies are forbidden. */ +export function assertAgingAdvicePayload(payload: Record): void { + const allowed = new Set(["createdAt", "now", "decision", "risk"]); + const extra = Object.keys(payload).filter((key) => !allowed.has(key)); + if (extra.length > 0) { + throw new Error(`laya approval-aging payload has forbidden keys: ${extra.sort().join(",")}`); + } + if (typeof payload.createdAt !== "string" || !Number.isFinite(Date.parse(payload.createdAt))) { + throw new Error("laya approval-aging payload createdAt is invalid"); + } + if (typeof payload.now !== "number" || !Number.isFinite(payload.now)) { + throw new Error("laya approval-aging payload now is invalid"); + } + const decision = payload.decision; + if (!decision || typeof decision !== "object" || Array.isArray(decision) || Object.keys(decision as object).some((key) => key !== "kind")) { + throw new Error("laya approval-aging payload decision is invalid"); + } + const kind = (decision as { kind?: unknown }).kind; + if (typeof kind !== "string" || kind.length === 0) { + throw new Error("laya approval-aging payload decision.kind is invalid"); + } + if (payload.risk !== "medium" && payload.risk !== "high") { + throw new Error("laya approval-aging payload risk is invalid"); + } +} + +export function mapAdviceChoiceToAging(choice: string): AgingChoice | null { + return AGING_CHOICES.has(choice as AgingChoice) ? choice as AgingChoice : null; +} + +export interface ResolveApprovalAgingOverlayDeps { + env?: NodeJS.Dict; + ask?: LayaAsk; + now?: number; +} + +export function agingAdviceState(input: { + createdAt: string; + now: number; + decisionKind: string; + risk: ApprovalRiskLevel; +}): Record { + return { + createdAt: input.createdAt, + now: input.now, + decision: { kind: input.decisionKind }, + risk: input.risk, + }; +} + +/** Advisory overlay. Undefined when the flag is off, the item is not pending, or advice is invalid. */ +export async function resolveApprovalAgingOverlay( + input: { createdAt: string; decisionKind: string; risk: ApprovalRiskLevel }, + deps: ResolveApprovalAgingOverlayDeps = {}, +): Promise { + if (input.decisionKind !== "pending") return undefined; + const env = deps.env ?? process.env; + if (!layaEnabled(env)) return undefined; + const now = deps.now ?? Date.now(); + const payload = agingAdviceState({ ...input, now }); + assertAgingAdvicePayload(payload); + const ask = deps.ask ?? ((request) => askLaya(request, { env })); + try { + const answers = await ask({ + state: payload, + questions: { + aging: { + type: "choice", + instructions: "Suggest display copy for a pending approval's age. Advisory. Never grant or deny.", + criteria: { + nudge: "Still waiting and the owner should look soon", + wait: "Stale but still a normal pending wait", + "discuss-close": "Owner may discuss closing; copy only, never a verdict", + }, + }, + }, + }); + const selected = answers?.aging?.type === "choice" ? answers.aging.selected : undefined; + if (!selected) return undefined; + return mapAdviceChoiceToAging(selected) ?? undefined; + } catch { + return undefined; + } +} + +/** Projection-only: never persist the result and never post a decision. */ +export async function attachApprovalAgingOverlay( + context: ApprovalContext, + input: { createdAt: string; decisionKind: string }, + deps: ResolveApprovalAgingOverlayDeps = {}, +): Promise { + const { agingOverlay: _ignored, ...base } = context as ApprovalContext & { agingOverlay?: AgingChoice }; + const overlay = await resolveApprovalAgingOverlay({ + createdAt: input.createdAt, + decisionKind: input.decisionKind, + risk: base.risk, + }, deps); + if (!overlay) return base; + return { ...base, agingOverlay: overlay } as ApprovalContext; +} diff --git a/apps/server/src/approvals/service.ts b/apps/server/src/approvals/service.ts index 8a710c8b..526082eb 100644 --- a/apps/server/src/approvals/service.ts +++ b/apps/server/src/approvals/service.ts @@ -6,6 +6,7 @@ import { assertTurnWorkspace, executeTurn } from "../routes/turns.js"; import { resolvePositionAgentEngine } from "../agent-binding.js"; import { ApprovalStore, approvalIdentity } from "./store.js"; import { buildApprovalContext, redactApprovalText } from "./context.js"; +import { attachApprovalAgingOverlay, rankApprovalViews } from "./aging-overlay.js"; import { attachApprovalRiskOverlay } from "./risk-overlay.js"; import { canActForPolicy, policyProgress, resolveApprovalPolicy } from "./policy.js"; @@ -251,7 +252,10 @@ export class ApprovalService { : undefined; const projected = publicView(a); const context = projected.context - ? await attachApprovalRiskOverlay(projected.context, a.action) + ? await attachApprovalAgingOverlay( + await attachApprovalRiskOverlay(projected.context, a.action), + { createdAt: a.createdAt, decisionKind: a.status }, + ) : projected.context; return { ...projected, @@ -268,7 +272,7 @@ export class ApprovalService { const { items } = await this.synchronize(ws); const views = await Promise.all(items.map(a => this.view(ws, a))); assertTurnWorkspace(this.ctx, ws); - return views; + return rankApprovalViews(views, Date.now()); }); } diff --git a/apps/server/test/approval-aging-overlay.test.ts b/apps/server/test/approval-aging-overlay.test.ts new file mode 100644 index 00000000..a72929a3 --- /dev/null +++ b/apps/server/test/approval-aging-overlay.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ApprovalContext } from "@roleweave/shared"; +import { layaEnabled } from "../src/laya/config.js"; +import { + agingAgeMs, + agingAdviceState, + agingNeedsAttention, + assertAgingAdvicePayload, + attachApprovalAgingOverlay, + mapAdviceChoiceToAging, + presentAgingQueue, + rankApprovalViews, + resolveApprovalAgingOverlay, +} from "../src/approvals/aging-overlay.js"; + +const now = Date.parse("2026-09-24T18:00:00.000Z"); +const createdAt = "2026-09-01T00:00:00.000Z"; +const baseContext: ApprovalContext = { + risk: "high", + requestedCapability: "exec", + impact: "command_execution", + permissions: { mode: "approval_required", allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable", reason: "engine_preview_not_supplied" }, + scope: { allowed: ["once"] }, +}; + +test("overlay cannot hide pending high items from the rule-risk layer", () => { + const now = Date.parse("2026-09-24T18:00:00.000Z"); + const highPending = { + id: "high-pending", + createdAt: "2026-09-01T00:00:00.000Z", + now, + decision: { kind: "pending" as const }, + risk: "high" as const, + }; + const mediumPending = { + id: "medium-pending", + createdAt: "2026-09-24T17:00:00.000Z", + now, + decision: { kind: "pending" as const }, + risk: "medium" as const, + }; + + const ranked = presentAgingQueue([mediumPending, highPending], { + "high-pending": { choice: "wait", needsAttention: false }, + "medium-pending": { choice: "nudge", needsAttention: true }, + }); + + const highIndex = ranked.findIndex((row) => row.id === "high-pending"); + const mediumIndex = ranked.findIndex((row) => row.id === "medium-pending"); + assert.notEqual(highIndex, -1, "pending high must remain visible when overlay says wait"); + assert.equal(ranked[highIndex]?.risk, "high"); + assert.ok(highIndex < mediumIndex, "pending high stays in the high rule-risk layer above medium"); +}); + +test("flag-off ranking preserves original order and never crosses the high risk layer", () => { + const mediumFirst = { id: "medium-pending", createdAt, now, decision: { kind: "pending" as const }, risk: "medium" as const }; + const highSecond = { id: "high-pending", createdAt, now, decision: { kind: "pending" as const }, risk: "high" as const }; + const unchanged = presentAgingQueue([mediumFirst, highSecond], {}); + assert.deepEqual(unchanged.map((row) => row.id), ["medium-pending", "high-pending"]); + + const ranked = rankApprovalViews([ + { id: "medium-pending", createdAt, status: "pending", context: { risk: "medium", agingOverlay: "nudge" } }, + { id: "high-pending", createdAt, status: "pending", context: { risk: "high", agingOverlay: "wait" } }, + ], now); + assert.deepEqual(ranked.map((row) => row.id), ["high-pending", "medium-pending"]); + assert.equal(ranked[0]?.context?.risk, "high"); +}); + +test("aging clock is deterministic against createdAt and does not invent expiry", () => { + assert.equal(agingAgeMs(createdAt, now), now - Date.parse(createdAt)); + assert.equal(agingNeedsAttention("wait"), false); + assert.equal(agingNeedsAttention("nudge"), true); +}); + +test("aging payload rejects bodies outside { createdAt, now, decision.kind, risk }", () => { + const payload = agingAdviceState({ createdAt, now, decisionKind: "pending", risk: "high" }); + assert.doesNotThrow(() => assertAgingAdvicePayload(payload)); + assert.throws(() => assertAgingAdvicePayload({ ...payload, description: "rm -rf" })); + assert.throws(() => assertAgingAdvicePayload({ ...payload, target: "./secret" })); + assert.deepEqual(Object.keys(payload).sort(), ["createdAt", "decision", "now", "risk"]); + assert.deepEqual((payload.decision as { kind: string }), { kind: "pending" }); +}); + +test("unknown Laya choice does not become an aging overlay", () => { + assert.equal(mapAdviceChoiceToAging("deny"), null); + assert.equal(mapAdviceChoiceToAging("discuss-close"), "discuss-close"); +}); + +test("ROLEWEAVE_LAYA_ENABLED remains explicit opt-in", () => { + assert.equal(layaEnabled({}), false); +}); + +test("flag off: no aging overlay and ask is not invoked", async () => { + let called = false; + const result = await attachApprovalAgingOverlay(baseContext, { createdAt, decisionKind: "pending" }, { + env: {}, + now, + ask: async () => { + called = true; + return { aging: { type: "choice", selected: "nudge", probabilities: {}, confidence: 1 } }; + }, + }); + assert.equal(called, false); + assert.equal((result as ApprovalContext & { agingOverlay?: string }).agingOverlay, undefined); + assert.equal(result.risk, "high"); +}); + +test("expired items skip aging overlay so existing clock helpers remain authoritative", async () => { + let called = false; + const overlay = await resolveApprovalAgingOverlay( + { createdAt, decisionKind: "expired", risk: "high" }, + { + env: { ROLEWEAVE_LAYA_ENABLED: "1" }, + now, + ask: async () => { + called = true; + return { aging: { type: "choice", selected: "discuss-close", probabilities: { "discuss-close": 1 }, confidence: 1 } }; + }, + }, + ); + assert.equal(called, false); + assert.equal(overlay, undefined); +}); + +test("discuss-close is copy only and never posts a decision", async () => { + let posted = 0; + const attached = await attachApprovalAgingOverlay(baseContext, { createdAt, decisionKind: "pending" }, { + env: { ROLEWEAVE_LAYA_ENABLED: "1" }, + now, + ask: async (request) => { + assertAgingAdvicePayload(request.state as Record); + return { aging: { type: "choice", selected: "discuss-close", probabilities: { "discuss-close": 1 }, confidence: 1 } }; + }, + }); + assert.equal((attached as ApprovalContext & { agingOverlay?: string }).agingOverlay, "discuss-close"); + assert.equal(attached.risk, "high"); + assert.equal(posted, 0); +}); diff --git a/package-lock.json b/package-lock.json index 1b2b543f..aa4390db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9138,9 +9138,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/packages/shared/src/approvals.ts b/packages/shared/src/approvals.ts index 1f0c3cea..cc5fc05e 100644 --- a/packages/shared/src/approvals.ts +++ b/packages/shared/src/approvals.ts @@ -25,6 +25,8 @@ export interface ApprovalContext { scope: { allowed: Array<"once" | "run"> }; /** Projection-only Laya suggestion. Never authoritative for Tag color or policy.digest. */ riskOverlay?: ApprovalRiskLevel; + /** Projection-only Jev aging copy. Never a verdict and never hides pending high. */ + agingOverlay?: "nudge" | "wait" | "discuss-close"; } export interface ApprovalDecisionRequest { diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts index 86e9eba6..27047f0c 100644 --- a/packages/ui/src/locales/en.ts +++ b/packages/ui/src/locales/en.ts @@ -1460,6 +1460,10 @@ export const enCatalog: Record = { "apr.risk.high": "High risk", "apr.riskOverlay": "Laya suggested risk", "apr.suggestionNotAdopted": "suggestion not adopted", + "apr.agingOverlay": "Laya aging suggestion", + "apr.aging.nudge": "nudge", + "apr.aging.wait": "wait", + "apr.aging.discuss-close": "discuss-close", "apr.requestedCapability": "Requested capability", "apr.impact": "Potential impact", "apr.impact.workspace_write": "May create or change workspace data", diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts index cee8f5c5..c5af455f 100644 --- a/packages/ui/src/locales/zh.ts +++ b/packages/ui/src/locales/zh.ts @@ -1463,6 +1463,10 @@ export const zhCatalog: Record = { "apr.risk.high": "高风险", "apr.riskOverlay": "Laya 建议风险", "apr.suggestionNotAdopted": "建议未采纳", + "apr.agingOverlay": "Laya 老化建议", + "apr.aging.nudge": "提醒跟进", + "apr.aging.wait": "继续等待", + "apr.aging.discuss-close": "讨论后关闭", "apr.requestedCapability": "请求能力", "apr.impact": "可能影响", "apr.impact.workspace_write": "可能创建或修改工作区数据",