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 fb8f62afd3..a8982c6fc5 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 c014eb5640..be26125fd4 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.", @@ -2329,6 +2333,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 85b20d8543..7157ca5e6e 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 @@ -522,7 +522,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"), @@ -553,7 +560,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 @@ -682,7 +690,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, @@ -1348,7 +1356,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, @@ -2323,7 +2331,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 7c92af85b0..01e945be26 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. @@ -28,9 +29,11 @@ export function todoCreateRequest(text: string, goalId: string, summary: string) normalizedParameters: {goal_id: goalId, text: text.trim()}, summary}; } -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; }) { @@ -45,6 +48,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"); @@ -113,10 +118,17 @@ export function WorkspaceActionForm({ draft, workspaceGoals = [], onClose, onPre