diff --git a/apps/presentation/dashboard/src/data/action-source-basis.ts b/apps/presentation/dashboard/src/data/action-source-basis.ts index 622cc311b7..1f2baff596 100644 --- a/apps/presentation/dashboard/src/data/action-source-basis.ts +++ b/apps/presentation/dashboard/src/data/action-source-basis.ts @@ -9,8 +9,10 @@ const todoBasis = { // Decode existing backend contracts; effect authority and stale-source checks // remain in ChatActionService and the owning Todo/Goal services. +export const todoUpdateSourceBasisSchema = z.object({ schema_version: z.literal("loopx_chat_canonical_update_basis_v0"), ...todoBasis }); +export type TodoUpdateSourceBasis = z.infer; export const actionSourceBasisSchema = z.discriminatedUnion("schema_version", [ - z.object({ schema_version: z.literal("loopx_chat_canonical_update_basis_v0"), ...todoBasis }), + todoUpdateSourceBasisSchema, z.object({ schema_version: z.literal("loopx_chat_canonical_terminal_basis_v0"), ...todoBasis }), z.object({ schema_version: z.literal("loopx_goal_lifecycle_source_basis_v1"), diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 62fd4f375e..62fd631c53 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -3,7 +3,7 @@ import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES, type ExecutionHandoffMode } fro import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; import { parseTurnStep, type TurnStep } from "./turn-steps.js"; import { z } from "zod"; -import { actionSourceBasisSchema } from "./action-source-basis.js"; +import { actionSourceBasisSchema, todoUpdateSourceBasisSchema } from "./action-source-basis.js"; import { todoApplyResultMatchesRequest, @@ -651,6 +651,7 @@ export async function readTodoRequest(goalId: string, todoId: string, signal: Ab const result = z.object({ ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(), status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(), + review_basis: todoUpdateSourceBasisSchema.optional(), }).parse(await requestJson(`/api/chat/todo/detail?${query}`, {signal})); if (result.goal_id !== goalId || result.todo_id !== todoId) throw new Error("Task source changed"); return result; 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 4da8669fcf..566c20213c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -651,7 +651,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.priority ? {selection.item.priority} : null} {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} - + {selection.item.status === "blocked" ?

{t("drawer.blockedReason")}

{selection.item.blockedReason ?? t("drawer.blockedReasonMissing")}

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 07e6c6acfc..7dc173ee70 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -524,6 +524,17 @@ const en = { "drawer.taskDeferUntil": "Defer until", "drawer.taskDetails": "Todo details", "drawer.taskInfo": "Task information", + "drawer.requirementsEdit": "Edit requirements", + "drawer.requirementsLabel": "Complete task requirements", + "drawer.requirementsSave": "Save requirements", + "drawer.requirementsSaving": "Saving…", + "drawer.requirementsReadback": "Retry readback", + "drawer.requirementsEffect": "Update this task only. Existing acceptance may become stale; this does not start or stop a process or grant execution permission.", + "drawer.requirementsNotSaved": "The requirements were not verified as saved. Your draft is retained.", + "drawer.requirementsReadbackChanged": "The saved requirements could not be confirmed. Read the current source before editing again.", + "drawer.requirementsTooLong": "The action journal supports up to 8,000 characters. The original requirements are retained in full.", + "drawer.requirementsReload": "Read current requirements and retain draft", + "drawer.requirementsSaved": "Requirements saved and read back. Work was not started.", "drawer.requestLoading": "Showing the summary while the full request loads…", "drawer.requestError": "Only the summary is available. The full request could not be read.", "drawer.requestLocalOnly": "This source provides a summary. Open its local workspace to read the full request.", @@ -2082,6 +2093,17 @@ const zhCN: Record = { "drawer.taskDeferUntil": "暂缓至", "drawer.taskDetails": "Todo 详情", "drawer.taskInfo": "任务信息", + "drawer.requirementsEdit": "修改要求", + "drawer.requirementsLabel": "完整任务要求", + "drawer.requirementsSave": "保存要求", + "drawer.requirementsSaving": "正在保存…", + "drawer.requirementsReadback": "重试读回", + "drawer.requirementsEffect": "只修改此任务。已有验收可能失效;不会启动或停止进程,也不授予执行权限。", + "drawer.requirementsNotSaved": "尚未核实保存成功,草稿已保留。", + "drawer.requirementsReadbackChanged": "未能核实保存后的要求,请读取当前来源后再修改。", + "drawer.requirementsTooLong": "操作记录最多支持 8,000 字符,原始要求仍完整保留。", + "drawer.requirementsReload": "读取当前要求并保留草稿", + "drawer.requirementsSaved": "要求已保存并读回,未启动工作。", "drawer.requestLoading": "正在读取完整要求,暂时显示摘要…", "drawer.requestError": "当前仅有摘要,完整要求读取失败。", "drawer.requestLocalOnly": "此来源提供摘要,请在它的本机工作区读取完整要求。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index d8eaf2c340..e80db3bcca 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -7,6 +7,7 @@ import type { GoalAcceptanceObservation } from "../../data/goal-acceptance-obser import type { AttentionDetails } from "./attention-details"; import type { WorkspaceLoadError, WorkspaceReadScope } from "../../data/workspace-progressive-status"; import type { TodoItem } from "../../data/status"; +import type { TodoUpdateSourceBasis } from "../../data/action-source-basis"; import { goalWorkKind, type GoalHostThreadActivity, type WorkspaceGoalExecution } from "./goal-activity"; import { receiptRetryReadback } from "./monitor-readback"; import type { DigestAttentionSummary } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; @@ -530,6 +531,7 @@ export type WorkspaceGoalArchiveLoadState = | { phase: "error"; error: string }; export type PersonalWorkspaceCallbacks = { + onSaveTodoRequirements?: (todo: WorkspaceTodo, text: string, basis: TodoUpdateSourceBasis, retry?: WorkspaceActionPreview) => Promise; onApplyProposal?: (proposal: WorkspaceActionPreview) => void | Promise; onApplyAttention?: (attention: WorkspaceAttention) => void; onCancelProposal?: (proposal: WorkspaceActionPreview) => void; 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 689ee4db52..3b08b0d718 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 @@ -27,6 +27,7 @@ import { fetchGoalChannelTargets, fetchLarkConnections, fetchLoopXMode, + loadTypedAction, previewTypedAction, setupGoalChannel, stewardPrompts, @@ -1840,13 +1841,17 @@ export function PersonalWorkspacePage({ options: { keepGoalSelected?: boolean; lifecycleProjection?: GoalLifecycleProjection; - presentation?: "drawer" | "feedback"; + presentation?: "drawer" | "feedback" | "inline"; } = {}, ) { - if (proposal.reviewPlan && !proposal.reviewPlan.canApply) return; + if (proposal.reviewPlan && !proposal.reviewPlan.canApply) return proposal; if (proposal.actionKind === "team.plan" && proposal.status === "error" - && !proposal.reviewPlan?.retryOriginal) return; - const showDrawer = options.presentation !== "feedback"; + && !proposal.reviewPlan?.retryOriginal) return proposal; + const matchesTodoTarget = (observed: TypedActionProposal) => proposal.actionKind !== "todo.update" || ( + observed.normalized_parameters.goal_id === proposal.goalId + && (!proposal.sourceRequest || observed.normalized_parameters.todo_id === proposal.sourceRequest.normalizedParameters.todo_id) + ); + const showDrawer = options.presentation !== "feedback" && options.presentation !== "inline"; const inferredLifecycleChange = proposal.actionKind === "goal.lifecycle" && proposal.goalId && (proposal.lifecycleOperation === "stop" || proposal.lifecycleOperation === "resume") @@ -1885,11 +1890,12 @@ export function PersonalWorkspacePage({ if (proposal.actionKind === "todo.update") { void reconcileStatus(proposal.goalId ? [proposal.goalId] : undefined); } - return; + return applied; } const result = await applyTypedAction(proposal.previewId); if (result.proposal.proposal_id !== proposal.previewId || result.proposal.action_kind !== proposal.actionKind + || !matchesTodoTarget(result.proposal) || (proposal.actionKind === "goal.lifecycle" && ( result.proposal.normalized_parameters.goal_id !== proposal.goalId || lifecycleOperationFor(result.proposal) !== proposal.lifecycleOperation @@ -1904,7 +1910,7 @@ export function PersonalWorkspacePage({ if (lifecycleChange) { callbacks.onGoalActivationStateChange?.(lifecycleChange.goalId, lifecycleChange.previous); } - setSelection({ item: applied, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: applied, kind: "proposal" }); setActionFeedback( result.proposal.status === "stale" ? t("feedback.stale") @@ -1912,7 +1918,7 @@ export function PersonalWorkspacePage({ ? t("proposal.teamPlan.retryUnavailable") : t(`actionReview.${applied.reviewPlan!.reason}`), ); - return; + return applied; } setActionFeedback(t("feedback.completed", { title: applied.title })); // Keep the success receipt visible for reviewed actions. Direct actions @@ -1929,6 +1935,7 @@ export function PersonalWorkspacePage({ if (applied.actionKind === "goal.lifecycle" || applied.actionKind === "gate.resolve" || applied.actionKind === "todo.update") { void reconcileStatus(applied.goalId ? [applied.goalId] : undefined); } + return applied; } catch (error) { if (lifecycleChange) { callbacks.onGoalActivationStateChange?.(lifecycleChange.goalId, lifecycleChange.previous); @@ -1955,13 +1962,13 @@ export function PersonalWorkspacePage({ setProposals((current) => ({ ...current, [proposal.previewId]: gated })); // A newly discovered authority gate always deserves review, including // when the action started on the direct path. - setSelection({ item: gated, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: gated, kind: "proposal" }); setActionFeedback(t("feedback.gateRequired", { summary: gated.gate.summary })); if (proposal.actionKind === "goal.create" && proposal.goalId) { callbacks.onRefresh?.(); selectGoal(proposal.goalId); } - return; + return gated; } const stale = error instanceof ChatApiError && isStaleActionFailure(error.payload); const readbackMismatch = error instanceof ChatApiError && error.payload.error_code === "action_response_mismatch"; @@ -1970,24 +1977,25 @@ export function PersonalWorkspacePage({ const returned = error instanceof ChatApiError ? typedActionProposalSchema.safeParse(error.payload.proposal) : null; let stored = returned?.success ? returned.data : undefined; - if (!stored && ["team.plan", "goal.create"].includes(proposal.actionKind) && !readbackMismatch) { + if (!stored && ["team.plan", "goal.create", "todo.update"].includes(proposal.actionKind) && !readbackMismatch) { const readback = await actionReadback.refetch(); // Query errors retain cached data; it cannot certify this attempt. if (readback.isSuccess) stored = readback.data?.find(item => item.proposal_id === proposal.previewId); } if (stored?.proposal_id === proposal.previewId && stored.action_kind === proposal.actionKind - && (!["team.plan", "goal.create"].includes(proposal.actionKind) || stored.normalized_parameters.goal_id === proposal.goalId)) { + && matchesTodoTarget(stored) + && (!["team.plan", "goal.create", "todo.update"].includes(proposal.actionKind) || stored.normalized_parameters.goal_id === proposal.goalId)) { await actionReadback.acceptProposal(stored); const observed = workspaceProposal(stored, t); const reconciled = { ...observed, errorMessage: observed.status === "error" ? error instanceof Error ? error.message : String(error) : undefined }; setProposals(current => ({ ...current, [proposal.previewId]: reconciled })); - setSelection({ item: reconciled, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: reconciled, kind: "proposal" }); if (observed.reviewPlan?.creationProgress?.goalId) void callbacks.onRefresh?.(); setActionFeedback(stored.status === "stale" ? t("feedback.stale") : observed.reviewPlan?.interaction === "completed" ? t("feedback.completed", { title: observed.title }) : t("feedback.executionFailed", { error: error instanceof Error ? error.message : String(error) })); - return; + return reconciled; } const failed = { ...proposal, @@ -1998,8 +2006,9 @@ export function PersonalWorkspacePage({ status: (stale ? "stale" : "error") as "stale" | "error", }; setProposals((current) => ({ ...current, [proposal.previewId]: failed })); - setSelection({ item: failed, kind: "proposal" }); + if (options.presentation !== "inline") setSelection({ item: failed, kind: "proposal" }); setActionFeedback(t("feedback.executionFailed", { error: failed.errorMessage })); + return failed; } } @@ -2036,7 +2045,30 @@ export function PersonalWorkspacePage({ else setSelectedGoalTab("files"); callbacks.onOpenOutput?.(output); }, - onApplyProposal: applyProposal, + onApplyProposal: async proposal => { await applyProposal(proposal); }, + onSaveTodoRequirements: async (todo, text, basis, retry) => { + if (retry?.status === "applied") return retry; + let original = retry; + if (retry) { + const stored = await loadTypedAction(retry.previewId); + if (stored.action_kind !== "todo.update" || stored.normalized_parameters.goal_id !== todo.goalId + || stored.normalized_parameters.todo_id !== (todo.sourceTodoId ?? todo.todoId) + || stored.normalized_parameters.text !== text) { + throw new ChatApiError(t("actionReview.targetChanged"), {error_code: "action_response_mismatch"}); + } + await actionReadback.acceptProposal(stored); + original = {...workspaceProposal(stored, t), sourceRequest: retry.sourceRequest}; + if (original.status === "applied") return original; + } + const proposal = original ?? await createPreview({actionKind: "todo.update", + context: {goal_id: todo.goalId, kind: "todo", todo_id: todo.sourceTodoId ?? todo.todoId}, + idempotencyKey: `workspace-todo-requirements-${todo.todoId}-${Date.now().toString(36)}`, + normalizedParameters: {goal_id: todo.goalId, todo_id: todo.sourceTodoId ?? todo.todoId, + ...(todo.claimedBy ? {agent_id: todo.claimedBy} : {}), operation: "edit", text, review_basis: basis}, + summary: t("drawer.requirementsSave"), + }, {select: false}); + return applyProposal(proposal, {presentation: "inline"}); + }, onExplainDecision: callbacks.onExplainDecision ?? ((attention) => draftAttentionMessage(attention, "explain")), onReplyToAttention: callbacks.onReplyToAttention ?? draftAttentionMessage, onCancelProposal: async (proposal) => { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index a48aa2b6ec..0a2c4d4138 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1235,6 +1235,14 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-context-drawer[data-context-kind="todo"] .personal-drawer-body { padding: 0; } .personal-task-inspector-summary { padding: 24px 24px 20px; border-bottom: 1px solid var(--pw-line); background: #fff; } .personal-task-inspector-summary h3 { margin: 14px 0 0; font-size: var(--text-heading); font-weight: 600; letter-spacing: -.015em; line-height: var(--leading-heading); overflow-wrap: anywhere; } +.personal-task-requirements-editor { display: grid; gap: 12px; margin-top: 16px; } +.personal-task-requirements-editor label { display: grid; gap: 8px; font-weight: 500; } +.personal-task-requirements-editor textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; color: var(--pw-text); background: var(--pw-card); font: inherit; line-height: var(--leading-prose); resize: vertical; } +.personal-task-requirements-editor p { margin: 0; color: var(--pw-muted); font-size: var(--text-caption); } +.personal-task-requirements-editor > div { display: flex; flex-wrap: wrap; gap: 8px; } +.personal-task-requirements-editor [role="alert"] { display: grid; } +.personal-task-requirements-editor [role="alert"] p { color: var(--pw-red); } +.personal-drawer-body:has(.personal-task-requirements-editor) > .personal-task-inspector-actions { display: none; } .personal-task-inspector-status { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } .personal-task-inspector-status > span { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; padding: 2px 8px; border-radius: 6px; background: #f1f2f5; color: #62697a; font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 600; } .personal-task-inspector-status > span:first-child { padding-left: 7px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx index bec4c37ee6..7812e5b482 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx @@ -1,39 +1,101 @@ -import {useEffect, useState} from "react"; +import {useEffect, useRef, useState} from "react"; import {readTodoRequest} from "../../data/chat"; +import type {TodoUpdateSourceBasis} from "../../data/action-source-basis"; import {useWorkspaceI18n} from "./i18n"; import {MarkdownText} from "./markdown"; -import type {WorkspaceTodo} from "./personal-workspace-model"; +import type {PersonalWorkspaceCallbacks, WorkspaceActionPreview, WorkspaceTodo} from "./personal-workspace-model"; -// Per-open read state, never a second Task store. A different selection or -// source invalidates both the in-flight read and its earlier successful body. +// Per-open read/draft state, never a second Task or execution owner. type RequestRead = {identity: string} & ( - | {phase: "loading" | "error"; text?: never} - | {phase: "ready"; text: string} + | {phase: "loading" | "error"; detail?: never} + | {phase: "ready"; detail: Awaited>} ); -export function TaskRequest({todo, local}: {todo: WorkspaceTodo; local: boolean}) { +export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local: boolean; + onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) { const {t} = useWorkspaceI18n(); const sourceId = todo.sourceTodoId; const identity = JSON.stringify([local, todo.goalId, sourceId, todo.requestText, todo.status]); const [read, setRead] = useState(null); const [retry, setRetry] = useState(0); + const [draft, setDraft] = useState<{text: string; basis: TodoUpdateSourceBasis} | null>(null); + const [pending, setPending] = useState(null); + const [saving, setSaving] = useState(false); + const [failure, setFailure] = useState(null); + const [saved, setSaved] = useState(false); + const savingRef = useRef(false); + const rebaseRef = useRef(false); + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); useEffect(() => { if (!local || !sourceId) return; const controller = new AbortController(); setRead({identity, phase: "loading"}); - void readTodoRequest(todo.goalId, sourceId, controller.signal).then(result => { - if (!controller.signal.aborted) setRead({identity, phase: "ready", text: result.text}); + void readTodoRequest(todo.goalId, sourceId, controller.signal).then(detail => { + if (controller.signal.aborted) return; + setRead({identity, phase: "ready", detail}); + if (rebaseRef.current && detail.review_basis) { + const basis = detail.review_basis; + setDraft(previous => previous ? {...previous, basis} : null); + setPending(null); + setFailure(null); + } + rebaseRef.current = false; }).catch(() => { if (!controller.signal.aborted) setRead({identity, phase: "error"}); }); return () => controller.abort(); }, [identity, local, retry, todo.goalId, sourceId]); const current = local && read?.identity === identity ? read : null; + const detail = current?.phase === "ready" ? current.detail : null; + const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis + && detail.archive_state === "active" && detail.status !== "done"; + async function save() { + if (!onSave || !draft || !sourceId || savingRef.current) return; + savingRef.current = true; + setSaving(true); setFailure(null); setSaved(false); + try { + const text = draft.text.replace(/\s+/gu, " ").trim(); + const proposal = await onSave(todo, text, draft.basis, pending ?? undefined); + if (!mounted.current) return; + setPending(proposal); + if (proposal.status !== "applied" || proposal.reviewPlan?.interaction !== "completed") { + throw new Error(proposal.errorMessage ?? proposal.gate?.summary + ?? (proposal.reviewPlan ? t(`actionReview.${proposal.reviewPlan.reason}`) : t("drawer.requirementsNotSaved"))); + } + const observed = await readTodoRequest(todo.goalId, sourceId, new AbortController().signal); + if (!mounted.current) return; + setRead({identity, phase: "ready", detail: observed}); + if (observed.text !== text) throw new Error(t("drawer.requirementsReadbackChanged")); + setDraft(null); setPending(null); setSaved(true); + } catch (error) { + if (mounted.current) setFailure(error instanceof Error ? error.message : String(error)); + } finally { + savingRef.current = false; + if (mounted.current) setSaving(false); + } + } return
- + {!draft ? : null} {current?.phase !== "ready" ?
{t(!local ? "drawer.requestLocalOnly" : !sourceId ? "drawer.requestUnidentified" : current?.phase === "error" ? "drawer.requestError" : "drawer.requestLoading")} {current?.phase === "error" ? : null}
: null} + {editable && !draft ? : null} + {draft ?
{event.preventDefault(); void save();}}> +