diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index ad07705f24..d6e34ac46a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1046,7 +1046,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.status === "error" && !selection.item.reviewPlan?.retryOriginal ?
{selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}{selection.item.errorMessage ? {selection.item.errorMessage} : null}{t(selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retryHint" : "drawer.proposalApplyFailedHint")}
: null} {selection.item.status === "rejected" ?

{t("drawer.proposalRejected")}

: null} {selection.item.status === "deferred" ?

{t("drawer.proposalDeferred")}

: null} - {selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} + {selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} {selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ? (() => { const fieldValue = (key: string) => selection.item.fields.find((field) => field.key === key)?.value; const gateGoalId = fieldValue("goal_id"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index a5bbf24b08..46744da4be 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -583,6 +583,17 @@ function lifecycleOperationFor(proposal: TypedActionProposal): GoalLifecycleOper : undefined; } +function workspaceCandidatesFromGate(gate: Record | null | undefined) { + const candidates = Array.isArray(gate?.candidates) ? gate.candidates : []; + return candidates.flatMap((candidate) => { + if (!candidate || typeof candidate !== "object") return []; + const item = candidate as Record; + return typeof item.workspace_ref === "string" && typeof item.label === "string" + ? [{ label: item.label, workspaceRef: item.workspace_ref }] + : []; + }); +} + function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate): WorkspaceActionPreview { const lifecycleOperation = lifecycleOperationFor(proposal); const reviewPlan = compileActionReviewPlan(proposal); @@ -591,6 +602,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) : typeof proposal.normalized_parameters.goal_id === "string" ? proposal.normalized_parameters.goal_id : ""; + const workspaceCandidates = workspaceCandidatesFromGate(proposal.gate); const target = typeof proposal.normalized_parameters.target === "string" ? proposal.normalized_parameters.target : ""; @@ -649,6 +661,14 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) nextAction: typeof proposal.gate.next_action === "string" ? proposal.gate.next_action : undefined, summary: String(proposal.gate.summary ?? t("proposal.gate.default")), } : undefined, + sourceRequest: proposal.status === "gated" && proposal.action_kind === "goal.create" ? { + actionKind: proposal.action_kind, + context: proposal.context, + idempotencyKey: `${proposal.proposal_id}-workspace`, + normalizedParameters: proposal.normalized_parameters, + summary: proposal.summary, + } : undefined, + workspaceCandidates, primaryLabel: reviewPlan.retryOriginal ? t("drawer.retryOriginal") : proposal.action_kind === "operation.execute" ? operationFrame?.kind === "result" ? operationFrame.resultDeliveryVerified @@ -1176,14 +1196,7 @@ export function PersonalWorkspacePage({ const rawGate = error.payload.gate && typeof error.payload.gate === "object" ? error.payload.gate as Record : {}; - const rawCandidates = Array.isArray(rawGate.candidates) ? rawGate.candidates : []; - const workspaceCandidates = rawCandidates.flatMap((candidate) => { - if (!candidate || typeof candidate !== "object") return []; - const item = candidate as Record; - return typeof item.workspace_ref === "string" && typeof item.label === "string" - ? [{ label: item.label, workspaceRef: item.workspace_ref }] - : []; - }); + const workspaceCandidates = workspaceCandidatesFromGate(rawGate); const gateKind = String(rawGate.kind ?? "workspace_selection_required"); const requiresAgentBinding = gateKind === "agent_binding_required" || gateKind === "agent_identity_selection_required"; diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 4888f1a8e4..f193a0d0f7 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -219,6 +219,76 @@ export const typedActionsScenario = { await operationUi.close(); } + const storedWorkspaceGate = { + schema_version: "loopx_chat_action_proposal_v1", + proposal_id: "stored-goal-create-workspace-gate", + action_kind: "goal.create", + summary: "Create stored workspace Goal", + normalized_parameters: { goal_id: "product-release", title: "Stored workspace Goal", workspace_ref: "current" }, + context: { kind: "goal_directory", goal_id: "product-release" }, + expected_state_fingerprint: "fixture-workspace-gate-r1", + permission_classification: "durable_write", + validation_evidence: ["workspace selection required"], + available_transitions: ["regenerate", "reject", "defer"], + status: "gated", + receipt: null, + stale: null, + gate: { + kind: "workspace_selection_required", + summary: "Select one server-configured workspace before creating this Goal.", + next_action: "Select a workspace to regenerate the confirmation preview.", + candidates: [{ workspace_ref: "workspace-fixture", label: "Workspace 1" }], + }, + created_at: "2026-09-14T01:00:00Z", + updated_at: "2026-09-14T01:00:01Z", + }; + const workspaceGateUi = await openWorkspacePage(browser, url, { + apiOptions: { initialActionProposals: [storedWorkspaceGate] }, + }); + try { + const { api, page } = workspaceGateUi; + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await page.locator(".personal-gated-summary summary").click(); + const proposalRow = page.locator(".personal-proposal-row", { hasText: "Stored workspace Goal" }); + try { + await proposalRow.waitFor({ state: "visible", timeout: 10_000 }); + } catch (error) { + throw new Error(`${error.message}; proposals=${await page.locator(".personal-proposal-row").allInnerTexts()}; errors=${workspaceGateUi.errors.join(" | ")}; body=${(await page.locator("body").innerText()).slice(0, 2000)}`); + } + await proposalRow.click(); + const drawer = page.locator('.personal-context-drawer[data-context-kind="proposal"]'); + if ((await drawer.innerText()).includes("需要宿主确认")) { + throw new Error("Workspace selection gate was mislabeled as host-only confirmation"); + } + const writesBeforeSelection = api.durableWriteCount; + await drawer.getByRole("button", { name: /Workspace 1/ }).click(); + await page.getByRole("button", { name: "创建 Goal 并开始首轮", exact: true }).waitFor({ state: "visible" }); + const regenerated = api.actionPreviews.at(-1); + if (regenerated?.normalized_parameters.workspace_ref !== "workspace-fixture") { + throw new Error(`Stored workspace selection did not regenerate the Goal preview: ${JSON.stringify(regenerated)}`); + } + if (api.durableWriteCount !== writesBeforeSelection) throw new Error("Selecting a workspace applied without confirmation"); + await page.getByRole("button", { name: "创建 Goal 并开始首轮", exact: true }).click(); + await page.getByText("已应用,LoopX 状态将刷新。", { exact: true }).waitFor({ state: "visible" }); + if (!api.actionApplies.includes(regenerated.proposalId)) throw new Error("Confirmation did not apply the regenerated preview"); + if (api.durableWriteCount !== writesBeforeSelection + 1) throw new Error("Confirmation did not write exactly one Goal"); + await page.reload({ waitUntil: "networkidle" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + const stored = await page.evaluate(async (proposalId) => { + const response = await fetch("/api/actions?goal_id=product-release"); + const payload = await response.json(); + return payload.proposals?.find((proposal) => proposal.proposal_id === proposalId); + }, regenerated.proposalId); + if (stored?.status !== "applied" || stored.receipt?.projection_verified !== true) { + throw new Error(`Confirmed workspace Goal did not survive readback: ${JSON.stringify(stored)}`); + } + } finally { + await workspaceGateUi.close(); + } + // Real Goal button -> typed preview -> compiler -> drawer/apply, with only // the service boundary controlled. No test computes the plan under review. for (const width of [1512, 390]) {