Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -1188,11 +1188,11 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
? <p className="personal-proposal-state is-stale">{t("proposal.primary.operationCancelled")}</p> : null}
{selection.item.status === "deferred" ? <p className="personal-proposal-state is-gated">{t("drawer.proposalDeferred")}</p> : null}
{selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ? <div className="personal-proposal-state is-gated"><span><strong>{t("drawer.gateDecisionRecheckTitle")}</strong>{t("drawer.gateDecisionRecheck")}</span></div>
: 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" ? <div className="personal-proposal-state is-gated"><span><strong>{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length || selection.item.gate?.kind === "agent_unavailable" ? selection.item.title : t("drawer.gateRequiresHost")}</strong>{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length || selection.item.gate?.kind === "agent_unavailable" ? selection.item.impact : t("drawer.gateRequiresHostDescription")}</span>{selection.item.gate?.nextAction ? <small>{selection.item.gate.nextAction}</small> : null}</div> : null}
{!readOnly && selection.item.workspaceCandidates?.length ? <div className="personal-workspace-candidates" aria-label={t("drawer.workspaceCandidates")}>{selection.item.workspaceCandidates.map((candidate) => <button key={candidate.workspaceRef} onClick={() => void callbacks.onSelectWorkspaceCandidate?.(selection.item, candidate.workspaceRef)} type="button"><strong>{candidate.label}</strong><small>{t("proposal.workspaceGate.candidateValue")}</small></button>)}</div> : null}
{!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" && (!["team.plan", "goal.create"].includes(selection.item.actionKind) || (selection.item.reviewPlan?.retryOriginal && selection.item.reviewPlan.canApply)) ? <button className="personal-primary-action" onClick={() => void (selection.item.reviewPlan?.retryOriginal ? callbacks.onApplyProposal?.(selection.item) : callbacks.onTransitionProposal?.(selection.item, "regenerate"))} type="button"><RotateCcw size={17} />{t(selection.item.reviewPlan?.retryOriginal ? "drawer.retryOriginal" : "drawer.proposalRegenerate")}</button> : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !(["team.plan", "goal.create"].includes(selection.item.actionKind) && ["error", "stale"].includes(selection.item.status)) && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? <button className="personal-primary-action" disabled={!['ready', 'deferred'].includes(selection.item.status) || selection.item.reviewPlan?.canApply === false} onClick={() => void callbacks.onApplyProposal?.(selection.item)} type="button"><Check size={17} />{selection.item.status === "applying" ? t("drawer.applying") : selection.item.primaryLabel ?? t("drawer.apply")}</button> : null}
{!readOnly && selection.item.actionKind !== "operation.execute" && !selection.item.reviewPlan?.creationProgress?.goalId && (["stale", "gated", "rejected"].includes(selection.item.status) || (selection.item.status === "ready" && selection.item.reviewPlan?.canApply === false)) ? <button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "regenerate")} type="button"><RotateCcw size={16} />{t("drawer.proposalRecheck")}</button> : null}
{!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ? <div className="personal-drawer-action-grid"><button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "defer")} type="button">{t("drawer.proposalDefer")}</button><button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "reject")} type="button">{t("drawer.decisionReject")}</button></div> : null}
{!readOnly && selection.item.persisted !== false && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ? <div className="personal-drawer-action-grid"><button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "defer")} type="button">{t("drawer.proposalDefer")}</button><button className="personal-secondary-action" onClick={() => void callbacks.onTransitionProposal?.(selection.item, "reject")} type="button">{t("drawer.decisionReject")}</button></div> : null}
{!["applied", "applying"].includes(selection.item.status) ? <button className="personal-secondary-action" onClick={onClose} type="button">{t("drawer.proposalClose")}</button> : null}
</>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { WorkspaceTranslate } from "./i18n";
import type { WorkspaceActionPreviewRequest } from "./personal-workspace-model";
import type { WorkspaceActionPreviewRequest, WorkspaceAgentOption } from "./personal-workspace-model";

type GoalContent = { objective: string; completion: string; boundary: string };

/** A read-only conversation projection cannot be the first Goal's executor. */
export function goalCreationAgentId(selectedId: string, agents: readonly WorkspaceAgentOption[]): string {
const selected = agents.find((agent) => agent.agentId === selectedId);
if (selected?.adapterKind !== "status_projection") return selectedId;
const executors = agents.filter((agent) => agent.adapterKind !== "status_projection");
return executors.find((agent) => agent.available)?.agentId ?? executors[0]?.agentId ?? selectedId;
}

export function goalCreateContent(input: GoalContent, t: WorkspaceTranslate) {
const objective = input.objective.trim();
const completion = input.completion.trim();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,14 @@ const en = {
"proposal.workspaceGate.defaultSummary": "Select the Goal workspace.",
"proposal.workspaceGate.selectionImpact": "After selecting a workspace, LoopX will show the confirmation preview again. The selection itself does not write state.",
"proposal.workspaceGate.selectionTitle": "Select Goal workspace",
"proposal.agentGate.title": "{agent} is not ready to start this Goal",
"proposal.agentGate.summary": "The Agent that would run this Goal is not available right now: {reason}.",
"proposal.agentGate.reason.executable_not_found": "the command {executable} was not found on this machine",
"proposal.agentGate.reason.api_key_not_set": "the environment variable {variable} is not set",
"proposal.agentGate.reason.unregistered": "it is not registered in this LoopX",
"proposal.agentGate.reason.unavailable": "the catalog marks it unavailable without a more specific reason",
"proposal.agentGate.nextAction": "Resolve the reported availability issue for {agent}, then recheck this request. The home page's runtime notice shows the current check.",
"proposal.agentGate.impact": "No Goal has been written. The same form and choices stay available.",
"projection.agentWorkQueued": "Agent work is queued for this Goal",
"projection.agentIdle": "Nothing needs your attention",
"projection.agentNeedsDecision": "Agent is waiting for your decision",
Expand Down Expand Up @@ -2579,6 +2587,14 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"proposal.workspaceGate.defaultSummary": "请选择 Goal 所属工作区。",
"proposal.workspaceGate.selectionImpact": "选择工作区后会重新展示待确认操作,选择本身不会写入状态。",
"proposal.workspaceGate.selectionTitle": "选择 Goal 工作区",
"proposal.agentGate.title": "{agent} 还不能开始这个 Goal",
"proposal.agentGate.summary": "负责推进这个 Goal 的 Agent 当前不可用:{reason}。",
"proposal.agentGate.reason.executable_not_found": "本机没有找到命令 {executable}",
"proposal.agentGate.reason.api_key_not_set": "环境变量 {variable} 未设置",
"proposal.agentGate.reason.unregistered": "它没有在这个 LoopX 中登记",
"proposal.agentGate.reason.unavailable": "目录将它标为不可用,未提供更具体原因",
"proposal.agentGate.nextAction": "根据上述原因修复 {agent} 的可用性,再按最新状态重新检查。首页的运行时提示会显示当前检查结果。",
"proposal.agentGate.impact": "没有写入任何 Goal,表单与已填内容仍可继续使用。",
"projection.agentWorkQueued": "这个 Goal 有待 Agent 推进的工作",
"projection.agentIdle": "暂无需要你处理",
"projection.agentNeedsDecision": "Agent 等待你的决定",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { goalCreateRequest } from "./goal-create-request.ts";
import { goalCreateRequest, goalCreationAgentId } from "./goal-create-request.ts";
import { localizedCapabilityFieldCopy } from "./capability-localization.ts";
import { taskSessionStatus } from "./goal-activity.ts";
import "./monitor-readback.test.mjs";

const source = (name) => readFileSync(new URL(name, import.meta.url), "utf8");
const projectionAgent = { agentId: "status-only", adapterKind: "status_projection", available: true, label: "Status" };
const missingAgent = { agentId: "codex", available: false, label: "Codex" };
const availableAgent = { agentId: "claude", available: true, label: "Claude" };
assert.equal(goalCreationAgentId("status-only", [missingAgent, projectionAgent]), "codex", "Creation diagnoses the real missing executor, never the read-only snapshot");
assert.equal(goalCreationAgentId("status-only", [missingAgent, availableAgent, projectionAgent]), "claude", "Creation can use the discovered available executor");
assert.equal(goalCreationAgentId("codex", [missingAgent, availableAgent, projectionAgent]), "codex", "An explicit unavailable executor remains selected for its own gate");
assert.equal(goalCreationAgentId("custom", [missingAgent, projectionAgent]), "custom", "An unknown explicit identity is left for backend eligibility");
assert.equal(goalCreationAgentId("status-only", [projectionAgent]), "status-only", "An empty executor catalog cannot manufacture an executor");
const answerText = source("./answer-text.ts");
const model = source("./personal-workspace-model.ts");
const activity = source("./goal-activity.ts");
Expand Down Expand Up @@ -692,3 +700,13 @@ for (const route of ["./views/frontstage-developer-page", "./views/benchmark-stu
assert.equal((page.match(/<Suspense fallback=\{<p className="personal-lazy-loading" role="status">/g) ?? []).length, 1, "The lazy settings site shows a loading status");

console.log("personal workspace drawer contract smoke passed");

// An unavailable first Agent: the service refuses the creation preview with a
// typed gate (kind, agent, catalog reason) and the page tells the owner in
// their language, never with the protocol sentence; nothing is written.
assert.match(page, /const agentGate = gateKind === "agent_unavailable"/, "The page maps the typed Agent gate to owner-language copy");
assert.match(page, /reasonCode === "executable_not_found"[\s\S]*?reasonCode === "api_key_not_set"[\s\S]*?rawGate\.registered === false/, "The reason follows the catalog's own fields");
assert.match(source("./context-drawer.tsx"), /selection\.item\.gate\?\.kind === "agent_unavailable" \? selection\.item\.title/, "The drawer shows the Agent gate's own title, not host confirmation");
for (const key of ["proposal.agentGate.title", "proposal.agentGate.summary", "proposal.agentGate.reason.executable_not_found", "proposal.agentGate.reason.api_key_not_set", "proposal.agentGate.reason.unregistered", "proposal.agentGate.reason.unavailable", "proposal.agentGate.nextAction", "proposal.agentGate.impact"]) {
assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ export type WorkspaceActionPreview = {
// See proposal-recency.ts.
createdAt?: string;
previewId: string;
/** False for a refused preview held only in this page; no stored action exists to read back. */
persisted?: boolean;
regeneratedFrom?: string | null;
primaryLabel?: string;
errorMessage?: string;
Expand Down
Loading
Loading