Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof todoUpdateSourceBasisSchema>;
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"),
Expand Down
3 changes: 2 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<unknown>(`/api/chat/todo/detail?${query}`, {signal}));
if (result.goal_id !== goalId || result.todo_id !== todoId) throw new Error("Task source changed");
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.item.priority ? <span>{selection.item.priority}</span> : null}
<span>{selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")}</span>
</div>
<TaskRequest todo={selection.item} local={!readOnly} />
<TaskRequest key={`${selection.item.goalId}:${selection.item.todoId}`} todo={selection.item} local={!readOnly}
onSave={!todoCompleted ? callbacks.onSaveTodoRequirements : undefined} />
{selection.item.status === "blocked" ? <section aria-label={t("drawer.blockedReason")} className="personal-task-blocked-reason" data-recorded={selection.item.blockedReason ? "true" : "false"}>
<h4><AlertCircle size={14} />{t("drawer.blockedReason")}</h4>
<p>{selection.item.blockedReason ?? t("drawer.blockedReasonMissing")}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -2082,6 +2093,17 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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": "此来源提供摘要,请在它的本机工作区读取完整要求。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -530,6 +531,7 @@ export type WorkspaceGoalArchiveLoadState =
| { phase: "error"; error: string };

export type PersonalWorkspaceCallbacks = {
onSaveTodoRequirements?: (todo: WorkspaceTodo, text: string, basis: TodoUpdateSourceBasis, retry?: WorkspaceActionPreview) => Promise<WorkspaceActionPreview>;
onApplyProposal?: (proposal: WorkspaceActionPreview) => void | Promise<void>;
onApplyAttention?: (attention: WorkspaceAttention) => void;
onCancelProposal?: (proposal: WorkspaceActionPreview) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
fetchGoalChannelTargets,
fetchLarkConnections,
fetchLoopXMode,
loadTypedAction,
previewTypedAction,
setupGoalChannel,
stewardPrompts,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -1904,15 +1910,15 @@ 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")
: applied.actionKind === "team.plan" && applied.status === "error" && !applied.reviewPlan?.canApply
? 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
Expand All @@ -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);
Expand All @@ -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";
Expand All @@ -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,
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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) => {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading