Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.item.status === "error" ? <div className="personal-proposal-state is-error"><span>{selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}</span>{selection.item.errorMessage ? <small>{selection.item.errorMessage}</small> : null}<small>{t("drawer.proposalApplyFailedHint")}</small></div> : null}
{selection.item.status === "rejected" ? <p className="personal-proposal-state is-error">{t("drawer.proposalRejected")}</p> : null}
{selection.item.status === "deferred" ? <p className="personal-proposal-state is-gated">{t("drawer.proposalDeferred")}</p> : null}
{selection.item.status === "gated" ? <div className="personal-proposal-state is-gated"><span><strong>{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.gateRequiresHost")}</strong>{selection.item.actionKind === "operation.execute" ? selection.item.impact : t("drawer.gateRequiresHostDescription")}</span>{selection.item.gate?.nextAction ? <small>{selection.item.gate.nextAction}</small> : null}</div> : null}
{selection.item.status === "gated" ? <div className="personal-proposal-state is-gated"><span><strong>{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}</strong>{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}</span>{selection.item.gate?.nextAction ? <small>{selection.item.gate.nextAction}</small> : null}</div> : 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,17 @@ function lifecycleOperationFor(proposal: TypedActionProposal): GoalLifecycleOper
: undefined;
}

function workspaceCandidatesFromGate(gate: Record<string, unknown> | 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<string, unknown>;
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);
Expand All @@ -549,6 +560,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
: "";
Expand Down Expand Up @@ -599,6 +611,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: proposal.action_kind === "operation.execute"
? proposal.operation?.lifecycle_state === "outcome_observed"
? proposal.operation.result_delivery
Expand Down Expand Up @@ -1087,14 +1107,7 @@ export function PersonalWorkspacePage({
const rawGate = error.payload.gate && typeof error.payload.gate === "object"
? error.payload.gate as Record<string, unknown>
: {};
const rawCandidates = Array.isArray(rawGate.candidates) ? rawGate.candidates : [];
const workspaceCandidates = rawCandidates.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const item = candidate as Record<string, unknown>;
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";
Expand Down
51 changes: 51 additions & 0 deletions examples/personal-workspace-browser/typed-actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,57 @@ 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.locator(".personal-goal-link", { hasText: "Product Release" }).click();
await page.locator(".personal-goal-tabs button", { hasText: "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"]');
await drawer.getByRole("button", { name: /Workspace 1/ }).click();
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 ((await drawer.innerText()).includes("Host confirmation required")) {
throw new Error("Workspace selection gate was mislabeled as host-only confirmation");
}
} 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]) {
Expand Down
4 changes: 2 additions & 2 deletions loopx/web/chat/asset-retention.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2",
"assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2",
"assets/index-B7B_kVDP.css",
"assets/index-C6eFCYni.js"
"assets/index-DZ4wXv1S.js"
],
[
"assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2",
Expand All @@ -29,7 +29,7 @@
"assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2",
"assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2",
"assets/index-B7B_kVDP.css",
"assets/index-LRc2f6MH.js"
"assets/index-C6eFCYni.js"
]
]
}

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion loopx/web/chat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
content="LoopX 个人 Agent 工作区:在同一个频道里查看、纠偏并推进 Goal。"
/>
<title>LoopX 个人 Agent 工作区</title>
<script type="module" crossorigin src="/chat/assets/index-C6eFCYni.js"></script>
<script type="module" crossorigin src="/chat/assets/index-DZ4wXv1S.js"></script>
<link rel="stylesheet" crossorigin href="/chat/assets/index-B7B_kVDP.css">
</head>
<body>
Expand Down