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
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/src/data/chat-model.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
9 changes: 9 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{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}
{!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>{candidate.workspaceRef}</small></button>)}</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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -2329,6 +2333,10 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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 所属工作区。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,14 @@ function proposalStatus(status: TypedActionProposal["status"]): WorkspaceActionP
return status;
}

function proposalFields(parameters: Record<string, unknown>, t: WorkspaceTranslate) {
function workspaceBindingText(binding: NonNullable<TypedActionProposal["workspace_binding"]>, 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<string, unknown>, t: WorkspaceTranslate,
workspaceBinding?: TypedActionProposal["workspace_binding"]) {
const fieldLabels: Record<string, string> = {
agent_id: t("proposal.field.agentId"),
cadence: t("proposal.field.cadence"),
Expand Down Expand Up @@ -553,7 +560,8 @@ function proposalFields(parameters: Record<string, unknown>, 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2323,7 +2331,7 @@ export function PersonalWorkspacePage({
{composerSuggestions}
</div>
{!composerCollapsed ? <>
{actionDraft ? <WorkspaceActionForm draft={actionDraft} workspaceGoals={workspaceGoals} onClose={() => setActionDraft(null)} onPreview={async (request) => {
{actionDraft ? <WorkspaceActionForm draft={actionDraft} workspaceGoals={workspaceGoals} workspaceProjects={workspaceConversations?.projects ?? []} onClose={() => setActionDraft(null)} onPreview={async (request) => {
const proposal = await createPreview(request, { select: false });
if (proposal.gate?.kind === "workspace_unavailable") {
throw new Error(proposal.gate.summary);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<unknown>;
}) {
Expand All @@ -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<string>(draft.permission ?? "workspace_write_on_confirmation");
const [interval, setInterval] = useState(draft.kind === "heartbeat" ? "1" : "2");
const [unit, setUnit] = useState(draft.kind === "heartbeat" ? "d" : "h");
Expand Down Expand Up @@ -113,10 +118,17 @@ export function WorkspaceActionForm({ draft, workspaceGoals = [], onClose, onPre
<label>{zh ? "完成标准" : "Completion criteria"}<textarea aria-label={zh ? "完成标准" : "Completion criteria"} required value={completion} onChange={(event) => setCompletion(event.target.value)} /></label>
<label>{zh ? "执行边界(可选)" : "Execution boundary (optional)"}<textarea aria-label={zh ? "执行边界(可选)" : "Execution boundary (optional)"} value={boundary} onChange={(event) => setBoundary(event.target.value)} /></label>
<label>{zh ? "工作目录" : "Working directory"}<select aria-label={zh ? "工作目录" : "Working directory"} value={workspaceRef} onChange={(event) => setWorkspaceRef(event.target.value)}>
<option value="current">{zh ? "默认工作区(确认前检查)" : "Default workspace (check before confirming)"}</option>
{workspaceGoals.map((goal) => <option key={goal.goalId} value={goal.goalId}>{goal.title} · {goal.repository?.label ?? goal.goalId}</option>)}
<option value="current">{zh ? "默认工作区(检查配置时显示具体目录)" : "Default workspace (review shows which directory)"}</option>
{configuredWorkspaces.length ? <optgroup label={zh ? "Dashboard 工作区" : "Dashboard workspaces"}>
{configuredWorkspaces.map((project) => <option key={project.goal_workspace_ref} value={project.goal_workspace_ref}>{project.title}</option>)}
</optgroup> : null}
{workspaceGoals.length ? <optgroup label={zh ? "已有 Goal 的工作目录" : "Existing Goal directories"}>
{workspaceGoals.map((goal) => <option key={goal.goalId} value={goal.goalId}>{goal.title} · {goal.repository?.label ?? goal.goalId}</option>)}
</optgroup> : null}
</select></label>
<p>{zh ? "可沿用已有 Goal 的工作目录。未连接的目录需要先完成项目连接;选择目录不会增加执行权限。" : "Reuse an existing Goal’s working directory. Unconnected directories need project setup first; selecting a directory does not expand execution permissions."}</p>
<p>{zh
? <>检查配置时会显示具体目录。要添加其他目录,请用 <code>loopx dashboard --scan-path &lt;目录&gt;</code> 重新启动 Dashboard(每个项目一个)。选择目录不会增加执行权限。</>
: <>The review names the directory before anything is created. To add another directory, restart the Dashboard with <code>loopx dashboard --scan-path &lt;directory&gt;</code>, once per project. Choosing a directory does not expand execution permissions.</>}</p>
<label>{zh ? "执行权限" : "Execution permission"}<select aria-label={zh ? "执行权限" : "Execution permission"} value={permission} onChange={(event) => setPermission(event.target.value)}><option value="read_only">{zh ? "只读" : "Read only"}</option><option value="workspace_write_on_confirmation">{zh ? "确认后允许修改工作区" : "Allow workspace changes after confirmation"}</option></select></label>
</> : null}
</>}
Expand Down
Loading
Loading