From 0d4adf809956132de6c82a5b6266733c5899e398 Mon Sep 17 00:00:00 2001 From: Lihua Date: Sun, 11 Oct 2026 23:27:54 -0700 Subject: [PATCH 1/5] fix(chat-actions): bind and name the workspace a Goal creation resolves to A goal.create preview resolved its workspace on the server but returned "current", so the owner reviewed "Current local workspace" without seeing which directory the Goal would use, and workspace choices were labelled "Workspace N" with an opaque token. The preview now replaces the requested workspace_ref with the explicit reference it resolved (an existing Goal id or a configured workspace token). Apply then creates the Goal in exactly the reviewed workspace. The proposal also carries a typed workspace_binding: the directory name, whether it is a configured workspace or shared with a Goal, and whether the default chose it. Checkout paths are never included. Workspace choices are named by directory, with the parent directory name added only when two names collide. The selection gate explains how to add a directory with --scan-path. /api/chat/projects also returns each root's Goal-creation reference so the form can offer it. The steward commission path reuses the shared token helper. Refs #6231 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lihua --- .../native_chat/external_conversations.py | 4 +- loopx/chat_action_store.py | 3 + loopx/chat_actions.py | 78 ++++++++++++++----- loopx/chat_server.py | 13 +++- tests/test_chat_goal_workspace_selection.py | 63 +++++++++++++++ tests/test_chat_ordinary_project.py | 2 + 6 files changed, 138 insertions(+), 25 deletions(-) diff --git a/loopx/capabilities/native_chat/external_conversations.py b/loopx/capabilities/native_chat/external_conversations.py index aac124bbdd..e3a591866c 100644 --- a/loopx/capabilities/native_chat/external_conversations.py +++ b/loopx/capabilities/native_chat/external_conversations.py @@ -10,9 +10,9 @@ from pathlib import Path import re from datetime import datetime, timedelta, timezone -import hashlib from typing import Any +from ...chat_actions import goal_workspace_ref from ...chat_store import _atomic_write_json, _read_json from ...chat_attachments import normalize_chat_image_attachments from ...control_plane.quota.automation_cadence_readback import ( @@ -399,7 +399,7 @@ def _run_steward_action(self, row: dict[str, Any]) -> None: "objective": argument, "completion_criteria": "按明确委托返回可核验结果;原生执行结束不代表 LoopX 验收。", "execution_boundary": "只读所选工作区;不继承旧目标,不自动调度,不扩大宿主策略。", "agent_id": selected["binding"]["executor_endpoint_id"], - "workspace_ref": "workspace-" + hashlib.sha256(workspace.encode()).hexdigest()[:12], + "workspace_ref": goal_workspace_ref(Path(workspace)), "heartbeat": {"enabled": False}, "native_token_budget": row["command_argument"]["native_token_budget"]}}) row.update(proposal_id=proposal["proposal_id"], response=( diff --git a/loopx/chat_action_store.py b/loopx/chat_action_store.py index f63edbb11b..bf2a7f98af 100644 --- a/loopx/chat_action_store.py +++ b/loopx/chat_action_store.py @@ -201,6 +201,7 @@ def create_preview( available_transitions: Sequence[Any], idempotency_key: str, canonical_update_basis: Mapping[str, Any] | None = None, + workspace_binding: Mapping[str, Any] | None = None, ) -> dict[str, Any]: selected_kind = str(action_kind or "").strip() if selected_kind not in ACTION_KINDS: @@ -224,6 +225,8 @@ def create_preview( "available_transitions": list(available_transitions), **({"canonical_update_basis": dict(canonical_update_basis)} if canonical_update_basis is not None else {}), + **({"workspace_binding": dict(workspace_binding)} + if workspace_binding is not None else {}), }, path="preview", ) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index d2aa3d00ef..7a1a4d33e7 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -118,6 +118,26 @@ def _digest(payload: Any) -> str: return hashlib.sha256(stable.encode("utf-8")).hexdigest() +def goal_workspace_ref(root: Path) -> str: + """The Goal-creation token for one server-configured workspace root.""" + + return f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}" + + +def goal_workspace_labels(roots: Sequence[Path]) -> dict[Path, str]: + """Owner-facing names for workspace roots, without checkout paths. + + The directory name identifies a workspace; a parent name is added only when + two roots would otherwise read the same. + """ + + names = [root.name or str(root) for root in roots] + return { + root: (f"{root.parent.name}/{name}" if names.count(name) > 1 and root.parent.name else name) + for root, name in zip(roots, names) + } + + def _normalize_cadence(value: Any) -> str: candidate = str(value or "").strip().lower().replace("-", "_") aliases = { @@ -444,7 +464,7 @@ def _project_for_goal_create( # The native steward freezes its configured workspace. Existing # Goals and the single-Goal fallback cannot redirect a commission. candidates = [root for root in self.workspace_roots if root.is_dir() and (root / ".git").exists() - and workspace_ref == f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}"] + and workspace_ref == goal_workspace_ref(root)] if len(candidates) != 1: raise ValueError("the bound steward workspace is unavailable") return candidates[0], {"id": "", "repo": str(candidates[0]), "domain": "project-goal-control-plane", @@ -472,10 +492,7 @@ def _project_for_goal_create( source_goal = goals[0] if source_goal is None: workspace_candidates = [root for root in self.workspace_roots if root.is_dir()] - token_map = { - f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}": root - for root in workspace_candidates - } + token_map = {goal_workspace_ref(root): root for root in workspace_candidates} selected = (workspace_candidates[0] if workspace_ref == "current" and len(workspace_candidates) == 1 else token_map.get(workspace_ref)) @@ -487,26 +504,22 @@ def _project_for_goal_create( "adapter": {"kind": "generic_project_goal_v0"}, } if source_goal is None: + available = [root for root in self.workspace_roots if root.is_dir()] + labels = goal_workspace_labels(available) candidates = [ - { - "workspace_ref": f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}", - "label": f"Workspace {index}", - } - for index, root in enumerate( - ( - root - for root in self.workspace_roots - if root.is_dir() - ), - start=1, - ) + {"workspace_ref": goal_workspace_ref(root), "label": labels[root]} + for root in available ] raise ProtectedActionGate( "goal.create", gate={ "kind": "workspace_selection_required", "summary": "Select one server-configured workspace before creating this Goal.", - "next_action": "Choose an available workspace or a registered Goal workspace, then review again. Unlisted directories require server workspace setup.", + "next_action": ( + "Choose an available workspace or a registered Goal workspace, then review again. " + "To use another directory, restart the Dashboard with " + "`loopx dashboard --scan-path `, once per project." + ), "candidates": candidates, }, ) @@ -524,6 +537,27 @@ def _project_for_goal_create( ) return project, source_goal + def _goal_workspace_binding( + self, project: Path, source_goal: Mapping[str, Any], *, requested_ref: str + ) -> dict[str, Any]: + """The workspace a confirmed creation will use, as the owner reviews it. + + ``workspace_ref`` is the explicit reference apply resolves, so a + default request is confirmed against the workspace it resolved to. + """ + + goal_id = str(source_goal.get("id") or "") + roots = [root for root in self.workspace_roots if root.is_dir()] + label = (goal_workspace_labels(roots).get(project) if project in roots else None) or project.name + return { + "schema_version": "loopx_goal_workspace_binding_v0", + "workspace_ref": goal_id or goal_workspace_ref(project), + "label": label, + "source": "goal_workspace" if goal_id else "configured_workspace", + "selection": "default" if requested_ref == "current" else "explicit", + **({"goal_id": goal_id} if goal_id else {}), + } + def _apply_goal_create( self, proposal_id: str, proposal: dict[str, Any], parameters: dict[str, Any] ) -> dict[str, Any]: @@ -1121,8 +1155,9 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: raise ValueError("normalized_parameters and context must be objects") normalized = self._normalize(action_kind, parameters) canonical_update_basis = None + workspace_binding = None if action_kind == "goal.create": - project, _source_goal = self._project_for_goal_create( + project, source_goal = self._project_for_goal_create( { "normalized_parameters": normalized, "context": dict(context), @@ -1130,6 +1165,10 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: ) agent_id = str(normalized.get("agent_id") or "codex") eligibility = self._agent_eligibility(agent_id, project=project) + workspace_binding = self._goal_workspace_binding( + project, source_goal, requested_ref=str(normalized.get("workspace_ref") or "current") + ) + normalized["workspace_ref"] = workspace_binding["workspace_ref"] else: eligibility = None if action_kind == "operation.execute": @@ -1283,6 +1322,7 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: request.get("idempotency_key"), field="idempotency_key" ), canonical_update_basis=canonical_update_basis, + workspace_binding=workspace_binding, ) return ( self.store.arm_operation(str(proposal["proposal_id"])) diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 9bfeb40d8d..beea8f92ec 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -27,7 +27,7 @@ normalize_chat_image_attachments, validate_chat_turn_envelope, ) -from .chat_actions import ChatActionService, ProtectedActionGate +from .chat_actions import ChatActionService, ProtectedActionGate, goal_workspace_labels, goal_workspace_ref from .chat_action_store import ACTION_KINDS, ActionConflictError, ChatActionStore from .chat_goal_subagent_api import ( GoalSubagentConfigurationRequestMixin, @@ -1390,10 +1390,15 @@ def do_GET(self) -> None: if path == DEFAULT_EXTENSION_PROJECTION_PATH: return self._handle_extension_projection(parse_qs(urlparse(self.path).query)) if path == "/api/chat/projects": + contexts = self.server.runtime_controller.project_contexts.available() + roots = [Path(context["workspace_path"]) for context in contexts] + labels = goal_workspace_labels(roots) + # The same roots admit Goal creation; their creation reference lets + # the Goal form offer them without a second workspace listing. self._send_json({"ok": True, "projects": [ - {"project_ref": context["project_ref"], "title": Path(context["workspace_path"]).name, - "grant": context["grant"]} - for context in self.server.runtime_controller.project_contexts.available() + {"project_ref": context["project_ref"], "title": labels[root], + "grant": context["grant"], "goal_workspace_ref": goal_workspace_ref(root)} + for context, root in zip(contexts, roots) ]}) return if path == "/healthz": diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index 34d92fc5eb..c626c3a1bb 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -79,3 +79,66 @@ def test_bound_steward_rejects_goal_workspace_redirect(workspace_service): "normalized_parameters": {"workspace_ref": "second-goal"}, "context": {"kind": "manager", "goal_id": "first-goal", "binding_id": "steward-binding"}, }) + + +def preview(service, workspace_ref, context_goal=None, key="create"): + return service.preview({ + "action_kind": "goal.create", "summary": "Create a Goal", "idempotency_key": key, + "context": {"kind": "manager", "goal_id": context_goal}, + "normalized_parameters": {"goal_id": f"goal-{key}", "title": "New Goal", + "objective": "Ship it", "workspace_ref": workspace_ref}, + }) + + +@pytest.mark.parametrize("route", ["context_goal", "single_goal", "single_root"]) +def test_default_preview_binds_the_workspace_it_resolved(workspace_service, route): + # "Default workspace" is resolved once, shown to the owner by name, and the + # confirmed proposal carries the explicit reference apply will resolve, so + # confirmation cannot land in a different directory than the one reviewed. + service, registry, first, _ = workspace_service + if route != "context_goal": + data = json.loads(registry.read_text()) + data["goals"] = data["goals"][:1] if route == "single_goal" else [] + registry.write_text(json.dumps(data)) + if route == "single_root": + service.workspace_roots = (first,) + proposal = preview(service, "current", "first-goal" if route == "context_goal" else None) + + binding = proposal["workspace_binding"] + assert binding["label"] == "first" + assert binding["selection"] == "default" + assert proposal["normalized_parameters"]["workspace_ref"] == binding["workspace_ref"] + if route == "single_root": + assert binding["source"] == "configured_workspace" and "goal_id" not in binding + assert binding["workspace_ref"] == "workspace-" + hashlib.sha256(str(first).encode()).hexdigest()[:12] + else: + assert binding["source"] == "goal_workspace" + assert binding["workspace_ref"] == binding["goal_id"] == "first-goal" + assert resolve(service, binding["workspace_ref"])[0] == first + assert str(first) not in json.dumps(binding) + + +def test_explicit_configured_workspace_preview_names_that_workspace(workspace_service): + service, _, _, second = workspace_service + token = "workspace-" + hashlib.sha256(str(second).encode()).hexdigest()[:12] + binding = preview(service, token, "first-goal")["workspace_binding"] + assert binding == { + "schema_version": "loopx_goal_workspace_binding_v0", + "workspace_ref": token, "label": "second", + "source": "configured_workspace", "selection": "explicit", + } + + +def test_workspace_choices_are_named_by_directory_not_token(tmp_path): + roots = [tmp_path / "team-a" / "app", tmp_path / "team-b" / "app", tmp_path / "docs"] + for root in roots: + root.mkdir(parents=True) + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "goals": []})) + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), + registry_path=registry, workspace_roots=roots) + with pytest.raises(ProtectedActionGate) as error: + preview(service, "current") + gate = error.value.gate + assert [candidate["label"] for candidate in gate["candidates"]] == ["team-a/app", "team-b/app", "docs"] + assert "--scan-path" in gate["next_action"] diff --git a/tests/test_chat_ordinary_project.py b/tests/test_chat_ordinary_project.py index f931cbce9a..4877aff29e 100644 --- a/tests/test_chat_ordinary_project.py +++ b/tests/test_chat_ordinary_project.py @@ -70,6 +70,8 @@ def test_http_ordinary_project_continues_native_session_without_goal_or_portfoli assert status == 200 and len(projects["projects"]) == 1 project = projects["projects"][0] assert project["title"] == "notes" and "workspace_path" not in project + # The same root is offered to the Goal form by its creation reference. + assert project["goal_workspace_ref"].startswith("workspace-") body = {"context_kind": "project", "project_ref": project["project_ref"]} status, opened = request("/api/chat/sessions", body) assert status == 201, opened From 9bd812bcd60af48f1f5b3461245415bcac3e2451 Mon Sep 17 00:00:00 2001 From: Lihua Date: Sun, 11 Oct 2026 23:27:54 -0700 Subject: [PATCH 2/5] feat(dashboard): choose and review a Goal's working directory The Create Goal form now offers the Dashboard's configured workspaces next to existing Goal directories. The review names the effective directory, its source and whether the default chose it, from the proposal's workspace_binding. Workspace choices show directory names instead of tokens. The form explains how to add a directory with --scan-path. The packaged browser scenario now creates a Goal in a configured workspace no Goal used yet and checks the registry. It also checks that the default names the directory it resolves to before anything is created. Refs #6231 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lihua --- .../dashboard/src/data/chat-model.ts | 2 +- apps/presentation/dashboard/src/data/chat.ts | 9 ++++ .../personal-workspace/context-drawer.tsx | 2 +- .../src/features/personal-workspace/i18n.tsx | 8 ++++ .../personal-workspace-page.tsx | 18 +++++--- .../workspace-action-form.tsx | 20 +++++++-- .../goal-workspace-backend.py | 13 ++++-- .../goal-workspace-selection.mjs | 45 ++++++++++++++++++- 8 files changed, 101 insertions(+), 16 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts index 66bb18b38e..539ac72655 100644 --- a/apps/presentation/dashboard/src/data/chat-model.ts +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -1,7 +1,7 @@ import type { GoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; export type LoopXModeSettings = { agent_id: string; token_budget: number }; /** A host-granted workspace an ordinary conversation may be scoped to. */ -export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" | "workspace_write" }; +export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" | "workspace_write"; goal_workspace_ref?: string }; export type ChatTodo = { todo_id: string | null; diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a2e8722828..cfa73ca364 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -468,6 +468,15 @@ export const typedActionProposalSchema = z.object({ checkpoint: z.record(z.string(), z.unknown()).nullable().optional(), failure: z.record(z.string(), z.unknown()).nullable().optional(), canonical_update_basis: actionSourceBasisSchema.optional(), + // The workspace a Goal creation resolved to, named for the owner's review. + workspace_binding: z.object({ + schema_version: z.literal("loopx_goal_workspace_binding_v0"), + workspace_ref: z.string().min(1), + label: z.string().min(1), + source: z.enum(["goal_workspace", "configured_workspace"]), + selection: z.enum(["default", "explicit"]), + goal_id: z.string().min(1).optional(), + }).optional(), regenerated_from: z.string().nullable().optional(), operation: typedOperationEnvelopeSchema.nullable().optional(), created_at: z.string(), 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 7d71ef2948..830c83c305 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1162,7 +1162,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.status === "deferred" ?

{t("drawer.proposalDeferred")}

: null} {selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ?
{t("drawer.gateDecisionRecheckTitle")}{t("drawer.gateDecisionRecheck")}
: 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} - {!readOnly && selection.item.workspaceCandidates?.length ?
{selection.item.workspaceCandidates.map((candidate) => )}
: null} + {!readOnly && selection.item.workspaceCandidates?.length ?
{selection.item.workspaceCandidates.map((candidate) => )}
: 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)) ? : !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") ? : 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)) ? : null} {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ?
: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 7b940507c9..6800ba047c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -924,6 +924,10 @@ const en = { "proposal.teamPlan.gapReason.actionKindNotSupported": "this host does not ship that action kind", "proposal.workspace.current": "Current local workspace (no Repository bound)", "proposal.workspace.named": "{workspace} (execution environment only; does not bind a repository)", + "proposal.workspace.configured": "{workspace} · Dashboard workspace (execution environment only; does not bind a repository)", + "proposal.workspace.goal": "{workspace} · shared with an existing Goal (execution environment only; does not bind a repository)", + "proposal.workspace.default": "Default: {workspace}", + "proposal.workspaceGate.candidateValue": "Dashboard workspace", "proposal.workspaceGate.agentImpact": "Bind an Agent identity, then recheck the original action. No Goal has been written.", "proposal.workspaceGate.agentTitle": "Bind an Agent first", "proposal.workspaceGate.defaultSummary": "Select the Goal workspace.", @@ -2327,6 +2331,10 @@ const zhCN: Record = { "proposal.teamPlan.gapReason.actionKindNotSupported": "当前环境不支持此任务类型", "proposal.workspace.current": "当前本地工作区(未绑定 Repository)", "proposal.workspace.named": "{workspace}(仅提供执行环境,不会自动关联仓库)", + "proposal.workspace.configured": "{workspace} · Dashboard 工作区(仅提供执行环境,不会自动关联仓库)", + "proposal.workspace.goal": "{workspace} · 与已有 Goal 共用(仅提供执行环境,不会自动关联仓库)", + "proposal.workspace.default": "默认:{workspace}", + "proposal.workspaceGate.candidateValue": "Dashboard 工作区", "proposal.workspaceGate.agentImpact": "先完成 Agent 身份绑定,再重新检查原操作;当前没有写入 Goal。", "proposal.workspaceGate.agentTitle": "先绑定 Agent", "proposal.workspaceGate.defaultSummary": "请选择 Goal 所属工作区。", 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 c614484ea3..940937d852 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 @@ -520,7 +520,14 @@ function proposalStatus(status: TypedActionProposal["status"]): WorkspaceActionP return status; } -function proposalFields(parameters: Record, t: WorkspaceTranslate) { +function workspaceBindingText(binding: NonNullable, t: WorkspaceTranslate) { + const named = t(binding.source === "goal_workspace" ? "proposal.workspace.goal" : "proposal.workspace.configured", + { workspace: binding.label }); + return binding.selection === "default" ? t("proposal.workspace.default", { workspace: named }) : named; +} + +function proposalFields(parameters: Record, t: WorkspaceTranslate, + workspaceBinding?: TypedActionProposal["workspace_binding"]) { const fieldLabels: Record = { agent_id: t("proposal.field.agentId"), cadence: t("proposal.field.cadence"), @@ -551,7 +558,8 @@ function proposalFields(parameters: Record, t: WorkspaceTransla key, label: fieldLabels[key] ?? key.replaceAll("_", " "), value: key === "workspace_ref" - ? value === "current" + ? workspaceBinding ? workspaceBindingText(workspaceBinding, t) + : value === "current" ? t("proposal.workspace.current") : t("proposal.workspace.named", { workspace: String(value ?? "current") }) : Array.isArray(value) ? value.join(" · ") : typeof value === "object" && value !== null @@ -680,7 +688,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) : proposal.action_kind === "team.plan" ? teamPlanFields(proposal.normalized_parameters, t) : decision ? [] - : proposalFields(proposal.normalized_parameters, t).filter(field => !reviewPlan.creationProgress?.goalId + : proposalFields(proposal.normalized_parameters, t, proposal.workspace_binding).filter(field => !reviewPlan.creationProgress?.goalId || proposal.status === "applied" || ["objective", "completion_criteria", "execution_boundary", "permission", "workspace_ref"].includes(field.key)), goalId: typeof proposal.normalized_parameters.goal_id === "string" ? proposal.normalized_parameters.goal_id : undefined, @@ -1298,7 +1306,7 @@ export function PersonalWorkspacePage({ fields: workspaceCandidates.map((candidate) => ({ key: `workspace_ref:${candidate.workspaceRef}`, label: candidate.label, - value: candidate.workspaceRef, + value: t("proposal.workspaceGate.candidateValue"), })), gate: { kind: gateKind, @@ -2255,7 +2263,7 @@ export function PersonalWorkspacePage({ {composerSuggestions} {!composerCollapsed ? <> - {actionDraft ? setActionDraft(null)} onPreview={async (request) => { + {actionDraft ? setActionDraft(null)} onPreview={async (request) => { const proposal = await createPreview(request, { select: false }); if (proposal.gate?.kind === "workspace_unavailable") { throw new Error(proposal.gate.summary); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx index 751749bb5b..40921c0889 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx @@ -1,6 +1,7 @@ import { goalCreateContent, goalCreateRequest } from "./goal-create-request"; import { useEffect, useId, useRef, useState } from "react"; import { useWorkspaceI18n } from "./i18n"; +import type { ChatProject } from "../../data/chat-model"; import type { WorkspaceActionPreviewRequest, WorkspaceGoal } from "./personal-workspace-model"; // Action identity comes from an explicit control, never from the text being edited. @@ -22,9 +23,11 @@ function boundedLength(value: string) { return Array.from(value.split(/\s+/u).filter(Boolean).join(" ")).length; } -export function WorkspaceActionForm({ draft, workspaceGoals = [], onClose, onPreview }: { +export function WorkspaceActionForm({ draft, workspaceGoals = [], workspaceProjects = [], onClose, onPreview }: { draft: WorkspaceActionDraft; workspaceGoals?: WorkspaceGoal[]; + /** Dashboard-configured workspaces; each admits Goal creation by its own reference. */ + workspaceProjects?: ChatProject[]; onClose: () => void; onPreview: (request: WorkspaceActionPreviewRequest) => Promise; }) { @@ -39,6 +42,8 @@ export function WorkspaceActionForm({ draft, workspaceGoals = [], onClose, onPre const [completion, setCompletion] = useState(draft.completionCriteria ?? ""); const [boundary, setBoundary] = useState(draft.executionBoundary ?? ""); const [workspaceRef, setWorkspaceRef] = useState("current"); + const configuredWorkspaces = workspaceProjects.filter( + (project): project is ChatProject & { goal_workspace_ref: string } => Boolean(project.goal_workspace_ref)); const [permission, setPermission] = useState(draft.permission ?? "workspace_write_on_confirmation"); const [interval, setInterval] = useState(draft.kind === "heartbeat" ? "1" : "2"); const [unit, setUnit] = useState(draft.kind === "heartbeat" ? "d" : "h"); @@ -106,10 +111,17 @@ export function WorkspaceActionForm({ draft, workspaceGoals = [], onClose, onPre