diff --git a/apps/presentation/dashboard/docs/assets/goal-hierarchy/after-desktop.png b/apps/presentation/dashboard/docs/assets/goal-hierarchy/after-desktop.png new file mode 100644 index 0000000000..1c8d663c2f Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-hierarchy/after-desktop.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-hierarchy/after-phone.png b/apps/presentation/dashboard/docs/assets/goal-hierarchy/after-phone.png new file mode 100644 index 0000000000..9154980c6c Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-hierarchy/after-phone.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-hierarchy/before-desktop.png b/apps/presentation/dashboard/docs/assets/goal-hierarchy/before-desktop.png new file mode 100644 index 0000000000..1d3062b687 Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-hierarchy/before-desktop.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-hierarchy/before-phone.png b/apps/presentation/dashboard/docs/assets/goal-hierarchy/before-phone.png new file mode 100644 index 0000000000..a855d2b5fc Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-hierarchy/before-phone.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-hierarchy/invalid-root-desktop.png b/apps/presentation/dashboard/docs/assets/goal-hierarchy/invalid-root-desktop.png new file mode 100644 index 0000000000..ae31fa3a2e Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-hierarchy/invalid-root-desktop.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-hierarchy/invalid-root-phone.png b/apps/presentation/dashboard/docs/assets/goal-hierarchy/invalid-root-phone.png new file mode 100644 index 0000000000..491e4ab4ad Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-hierarchy/invalid-root-phone.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-hierarchy/unavailable-desktop.png b/apps/presentation/dashboard/docs/assets/goal-hierarchy/unavailable-desktop.png new file mode 100644 index 0000000000..ae31fa3a2e Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-hierarchy/unavailable-desktop.png differ diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index b7f760df8c..c21a9ccd98 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -32,7 +32,7 @@ "smoke:frontstage-route": "rm -rf /tmp/loopx-frontstage-route-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-frontstage-route-smoke smoke/frontstage-route-smoke.ts && node /tmp/loopx-frontstage-route-smoke/frontstage-route-smoke.js", "smoke:frontstage-share-bundle": "node ../../../examples/frontstage-share-bundle-smoke.mjs", "smoke:goal-ownership": "node smoke/goal-ownership-browser-smoke.mjs", - "smoke:goal-order": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --outDir node_modules/.cache/loopx-goal-order src/features/personal-workspace/goal-order.ts && node src/features/personal-workspace/goal-order.test.mjs", + "smoke:goal-order": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --outDir node_modules/.cache/loopx-goal-order src/features/personal-workspace/goal-order.ts src/features/personal-workspace/goal-hierarchy.ts && node src/features/personal-workspace/goal-order.test.mjs && node --test src/features/personal-workspace/goal-hierarchy.test.mjs", "smoke:goal-activity": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --outDir node_modules/.cache/loopx-goal-activity src/features/personal-workspace/goal-activity.ts && node src/features/personal-workspace/goal-activity.test.mjs", "smoke:proposal-recency": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --outDir node_modules/.cache/loopx-proposal-recency src/features/personal-workspace/proposal-recency.ts && node src/features/personal-workspace/proposal-recency.test.mjs", "smoke:home-browser": "node ../../../examples/dashboard-home-browser-smoke.mjs", diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index f655b87a8d..f1d4aa45ac 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -539,6 +539,7 @@ export const runRecordSchema = z.object({ }); export const runGoalSchema = z.object({ + parent_goal_id: z.string().nullable().optional().catch(null), goal_objective: z.string().nullable().optional().catch(null), zcode_goal_eligible_agent_ids: z.array(z.string()).optional().default([]).catch([]), acceptance_observation: goalAcceptanceObservationSchema.optional().nullable().catch(null), diff --git a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts index 9576f4c331..562798e148 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -15,6 +15,7 @@ const directorySchema = z.object({ registry_revision: z.string(), goals: z.array(z.object({ id: z.string(), + parent_goal_id: z.string().nullable().optional().catch(null), display_name: z.string(), activation_state: z.enum(["active", "stopped"]), registry_member: z.literal(true), @@ -52,6 +53,7 @@ export function reusableGoalSnapshots( const snapshot = previous.snapshots[goal.id]; if (!earlier || !snapshot || invalidated.has(goal.id)) return []; if (earlier.display_name !== goal.display_name + || earlier.parent_goal_id !== goal.parent_goal_id || earlier.activation_state !== goal.activation_state) return []; return [[goal.id, snapshot]]; })); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-hierarchy.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/goal-hierarchy.test.mjs new file mode 100644 index 0000000000..d9e597a774 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-hierarchy.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { goalHierarchy, goalSubtree, visibleGoalTree } from "../../../node_modules/.cache/loopx-goal-order/goal-hierarchy.js"; + +const goals = [ + {goalId: "daily"}, {goalId: "research"}, + {goalId: "portfolio", parentGoalId: "research"}, + {goalId: "candidate", parentGoalId: "portfolio"}, + {goalId: "graph", parentGoalId: "research"}, + {goalId: "orphan", parentGoalId: "not-readable"}, + {goalId: "cycle-a", parentGoalId: "cycle-b"}, + {goalId: "cycle-b", parentGoalId: "cycle-a"}, + {goalId: "self", parentGoalId: "self"}, +]; + +test("organization follows declared parents, with no implicit run or Agent relationship", () => { + assert.equal(goalSubtree(goals), goals, "Unscoped behavior retains the existing list"); + assert.deepEqual(goalSubtree(goals, "research").map(g => g.goalId), ["research", "portfolio", "candidate", "graph"]); + assert.deepEqual(goalSubtree(goals, "missing"), [], "Never substitute all work for an unavailable root"); + assert.deepEqual(visibleGoalTree(goals, new Set()).map(row => row.goal.goalId), + ["daily", "research", "orphan", "cycle-a", "cycle-b", "self"]); + assert.deepEqual(visibleGoalTree(goals, new Set(["research", "portfolio"])).slice(1, 5) + .map(row => [row.goal.goalId, row.depth]), [["research", 0], ["portfolio", 1], ["candidate", 2], ["graph", 1]]); +}); + +test("malformed ancestry is visible without fabricating links or looping", () => { + const tree = goalHierarchy(goals); + assert.deepEqual([...tree.invalidParents], ["orphan", "cycle-a", "cycle-b", "self"]); + assert.deepEqual(goalSubtree(goals, "cycle-a").map(goal => goal.goalId), ["cycle-a"]); + const deep = Array.from({length: 10_000}, (_, i) => ({goalId: String(i), parentGoalId: i ? String(i - 1) : null})); + assert.equal(goalSubtree(deep, "0").length, deep.length, "No call-stack limit truncates deep history"); + assert.equal(visibleGoalTree(deep, new Set(deep.map(g => g.goalId))).length, deep.length); +}); + +test("same logical IDs from different sources cannot reuse another source's parentage", () => { + assert.deepEqual(goalSubtree([{goalId: "research"}, {goalId: "benchmark-goal", parentGoalId: "research"}], "research") + .map(goal => goal.goalId), ["research", "benchmark-goal"]); + assert.deepEqual(goalSubtree([{goalId: "research"}, {goalId: "benchmark-goal"}], "research") + .map(goal => goal.goalId), ["research"]); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-hierarchy.ts b/apps/presentation/dashboard/src/features/personal-workspace/goal-hierarchy.ts new file mode 100644 index 0000000000..2f4819c581 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-hierarchy.ts @@ -0,0 +1,71 @@ +/** Organization inside one status source; never an execution or access grant. */ +export type GoalParent = { goalId: string; parentGoalId?: string | null }; + +export function goalHierarchy(goals: readonly T[]) { + const byId = new Map(goals.map(goal => [goal.goalId, goal])); + const parents = new Map(); + const invalidParents = new Set(); + for (const goal of goals) { + if (!goal.parentGoalId) continue; + if (byId.has(goal.parentGoalId)) parents.set(goal.goalId, goal.parentGoalId); + else invalidParents.add(goal.goalId); + } + // Break every edge in a cycle. Orphans and cyclic Goals remain visible roots; + // a malformed relation cannot hide work or make a parent its own descendant. + const visited = new Set(); + for (const goal of goals) { + const path: string[] = []; + const positions = new Map(); + let id: string | undefined = goal.goalId; + while (id !== undefined && !visited.has(id) && !positions.has(id)) { + positions.set(id, path.length); + path.push(id); + id = parents.get(id); + } + if (id !== undefined && positions.has(id)) { + for (const member of path.slice(positions.get(id))) { + parents.delete(member); + invalidParents.add(member); + } + } + for (const member of path) visited.add(member); + } + const children = new Map(); + for (const goal of goals) { + const parent = parents.get(goal.goalId) ?? null; + const siblings = children.get(parent) ?? []; + siblings.push(goal); + children.set(parent, siblings); + } + return { byId, parents, children, invalidParents }; +} + +export function goalSubtree(goals: T[], rootId?: string | null): T[] { + if (!rootId) return goals; + const tree = goalHierarchy(goals); + if (!tree.byId.has(rootId)) return []; + const included = new Set([rootId]); + const pending = [rootId]; + while (pending.length) { + for (const child of tree.children.get(pending.pop()!) ?? []) { + included.add(child.goalId); + pending.push(child.goalId); + } + } + return goals.filter(goal => included.has(goal.goalId)); +} + +export function visibleGoalTree(goals: readonly T[], expanded: ReadonlySet) { + const tree = goalHierarchy(goals); + const pending = (tree.children.get(null) ?? []).map(goal => ({ goal, depth: 0 })).reverse(); + const rows: Array<{ goal: T; depth: number; hasChildren: boolean }> = []; + while (pending.length) { + const row = pending.pop()!; + const children = tree.children.get(row.goal.goalId) ?? []; + rows.push({ ...row, hasChildren: children.length > 0 }); + if (expanded.has(row.goal.goalId)) { + for (let i = children.length - 1; i >= 0; i--) pending.push({ goal: children[i]!, depth: row.depth + 1 }); + } + } + return rows; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx index b20b390761..57616fe8d5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx @@ -1,7 +1,8 @@ -import { ArrowDown, ArrowUp, ArrowUpDown, Bot, ChevronDown, ChevronRight, LoaderCircle, Pause, Plus, RotateCcw, Settings2, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, Bot, ChevronDown, ChevronRight, Focus, LoaderCircle, Pause, Plus, RotateCcw, Settings2, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { DesktopUpdate } from "./desktop-update"; import { useGoalOrder } from "./use-goal-order"; +import { goalHierarchy, visibleGoalTree } from "./goal-hierarchy"; import { GoalIdentityMark, useGoalActivity } from "./goal-activity-view"; import { useWorkspaceI18n } from "./i18n"; @@ -32,6 +33,8 @@ export function GoalSidebar({ onRequestGoalLifecycle, onSelectGoal, selectedGoalId, + rootGoalId, + onChangeGoalRoot, statusSourceControl, }: { attentionCount: number; @@ -45,19 +48,47 @@ export function GoalSidebar({ onRequestGoalLifecycle?: (goal: WorkspaceGoal, operation: "stop" | "resume" | "delete") => void; onSelectGoal: (goalId: string | null) => void; selectedGoalId: string | null; + rootGoalId?: string; + onChangeGoalRoot?: (goalId: string | null) => void; statusSourceControl?: StatusSourceControl; }) { const { t } = useWorkspaceI18n(); const [sorting, setSorting] = useState(false); - const ordering = useGoalOrder(goals.filter((goal) => goal.activationState !== "stopped"), statusSourceControl?.activeSource.statusUrl ?? "/status.json"); + const tree = useMemo(() => goalHierarchy(goals), [goals]); + const siblings = (from: string, to: string) => tree.parents.get(from) === tree.parents.get(to); + const ordering = useGoalOrder(goals.filter((goal) => goal.activationState !== "stopped"), statusSourceControl?.activeSource.statusUrl ?? "/status.json", siblings); const activeGoals = ordering.sorted; const stoppedGoals = goals.filter((goal) => goal.activationState === "stopped"); + const siblingGroups = new Map(); + for (const goal of activeGoals) { + const parent = tree.parents.get(goal.goalId) ?? null; + const group = siblingGroups.get(parent) ?? []; + group.push(goal); + siblingGroups.set(parent, group); + } + const [expanded, setExpanded] = useState>(() => new Set(rootGoalId ? [rootGoalId] : [])); + const ancestors: string[] = []; + let parent = selectedGoalId ? tree.parents.get(selectedGoalId) : undefined; + while (parent) { ancestors.push(parent); parent = tree.parents.get(parent); } + const ancestorKey = JSON.stringify(ancestors); + useEffect(() => { + const parents: string[] = JSON.parse(ancestorKey); + if (parents.length) setExpanded(current => new Set([...current, ...parents])); + }, [ancestorKey]); + const hasTree = tree.parents.size > 0; + const toggle = (id: string) => setExpanded(current => { + const next = new Set(current); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); const lifecycleOperationEnabled = (operation: "stop" | "resume" | "delete") => ( Boolean(onRequestGoalLifecycle) && (!goalLifecycleOperations || goalLifecycleOperations.includes(operation)) ); - const goalRow = (goal: WorkspaceGoal, stopped: boolean) => ( -
+ const goalRow = (goal: WorkspaceGoal, stopped: boolean, depth = 0, hasChildren = false) => ( +
+ {hasChildren ? + : hasTree ? : null} {!stopped && sorting ?
- - + +
: null} {onRequestGoalLifecycle && lifecycleOperationEnabled(stopped ? "resume" : "stop") ? ( <> @@ -117,7 +149,7 @@ export function GoalSidebar({ {statusSourceControl ? : null}
) : null} - {stoppedGoals.map((goal) => goalRow(goal, true))} + {visibleGoalTree(stoppedGoals, expanded).map(({goal, depth, hasChildren}) => goalRow(goal, true, depth, hasChildren))}
) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 0b75f11f91..2296683256 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1369,6 +1369,14 @@ const en = { "sidebar.delete": "Delete", "sidebar.deleteGoal": "Delete Goal", "sidebar.manager": "LoopX Manager", + "sidebar.expandChildren": "Expand child Goals: {goal}", + "sidebar.collapseChildren": "Collapse child Goals: {goal}", + "sidebar.childOf": "Child Goal of {goal}", + "sidebar.invalidParent": "Parent relationship unavailable; shown independently", + "sidebar.focusGoal": "Only this Goal and its children", + "sidebar.allGoals": "All Goals", + "sidebar.scopeHelp": "Display scope only. Access, execution, budgets and each Goal’s acceptance stay independent.", + "sidebar.rootUnavailable": "This Goal scope is unavailable in the current source. Other Goals have not been substituted.", "header.conversationScope": "Scope", "header.scopeWorkspacesUnavailable": "Workspaces could not be read", "workspace.conversation": "Workspace conversation · {grant}", @@ -2946,6 +2954,14 @@ const zhCN: Record = { "sidebar.delete": "删除", "sidebar.deleteGoal": "删除 Goal", "sidebar.manager": "LoopX 管家", + "sidebar.expandChildren": "展开子 Goal:{goal}", + "sidebar.collapseChildren": "收起子 Goal:{goal}", + "sidebar.childOf": "父 Goal:{goal}", + "sidebar.invalidParent": "父子关系不可用,已独立显示", + "sidebar.focusGoal": "只看此 Goal 及子 Goal", + "sidebar.allGoals": "全部 Goals", + "sidebar.scopeHelp": "仅筛选展示。访问、执行、预算和各 Goal 验收仍独立。", + "sidebar.rootUnavailable": "当前来源中此 Goal 范围不可用,未替换为其他 Goal。", "header.conversationScope": "范围", "header.scopeWorkspacesUnavailable": "暂时无法读取工作区", "workspace.conversation": "工作区对话 · {grant}", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 86cb6b84da..90e8e24c54 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -599,7 +599,7 @@ assert.match( // state, not empty lanes, and health issues are owner-language sentences. assert.match(model, /kind: "no_goals"/, "The home carries a typed first-Goal classification"); assert.match(dashboard, /const onboarding: WorkspaceHomeOnboarding \| undefined = isHealthy/, "Only a healthy source classifies as a first-Goal workspace"); -assert.match(dashboard, /if \(!sourceError\) return loadedModel;[\s\S]*?onboarding: undefined,[\s\S]*?retryable: true,/, "A failed current read withdraws the first-Goal state and shows a retryable failure"); +assert.match(dashboard, /if \(!sourceError\) return \w+;[\s\S]*?onboarding: undefined,[\s\S]*?retryable: true,/, "A failed current read withdraws the first-Goal state and shows a retryable failure"); assert.match(dashboard, /onRefresh=\{async \(readScope = "all"\) => \{[\s\S]*?rejectOnError: true[\s\S]*?return \{ ok: false, error: formatStatusError\(error\) \}/, "A refresh reports its failure to the page instead of swallowing it"); assert.match(page, /if \(result && result\.ok === false\) \{\n\s*setRefreshState\("error"\);/, "The header reports a failed refresh and never an update"); assert.doesNotMatch(dashboard, /payload\.ok === false|契约检查未通过|系统健康关注点/, "Health issues are localized owner language, not protocol fragments"); 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 469c91573a..1b95312d10 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 @@ -158,6 +158,8 @@ export type WorkspaceGoalSubagentConfiguration = { }; export type WorkspaceGoal = { + /** Registry organization in this status source; not Agent ownership or execution dependency. */ + parentGoalId?: string | null; /** Bounded, safe text from this Goal's declared source document. */ objective?: string | null; acceptanceObservation?: GoalAcceptanceObservation | null; 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 6578cc1ee1..cd150d66f3 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 @@ -926,7 +926,11 @@ export function PersonalWorkspacePage({ statusSourceControl, serviceNotice, workspaceConversations, + rootGoalId, + onChangeGoalRoot, }: { + rootGoalId?: string; + onChangeGoalRoot?: (goalId: string | null) => void; workspaceConversations?: WorkspaceConversationDirectory; /** The bound Session's mode queues a message sent while its Turn runs. */ conversationQueuesFollowUps?: boolean; @@ -1151,6 +1155,7 @@ export function PersonalWorkspacePage({ [workspaceGoals], ); const selectedGoal = workspaceGoals.find((goal) => goal.goalId === selectedGoalId) ?? null; + const rootUnavailable = Boolean(rootGoalId && !selectedGoal); function openSettings(target: Extract, returnToModel = false) { settingsReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; settingsFromModelMenuRef.current = returnToModel; @@ -2745,6 +2750,7 @@ export function PersonalWorkspacePage({ followConversationRef.current = nearBottom; setShowLatestMessage(!nearBottom); }}> + {!rootUnavailable ? <> {selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && callbacks.onSelectWorkAgent ?
: null} {selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && !readOnly && selectedAgentId === "codex" && conversationSessionId && loopxMode?.settings.execution_config && loopxMode.settings.agent_id ? { selectGoal(goalId, "chat"); }} /> )} + : null} - : null} )} sidebar={( void requestGoalLifecycle(goal, operation)} 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 dda47243da..c130571d0c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -106,6 +106,9 @@ .personal-status-source-command button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; justify-self: end; min-height: 25px; padding: 0 8px; border: 1px solid var(--pw-line-strong); border-radius: 7px; background: #fff; color: var(--pw-blue-ink); cursor: pointer; font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 500; } .personal-status-source-command button:disabled { color: var(--pw-faint); cursor: not-allowed; } .personal-sidebar-nav { flex: 1; padding: 12px 12px; overflow: auto; } +.personal-goal-tree-toggle { display: grid; place-items: center; width: 22px; height: 44px; padding: 0; border: 0; background: transparent; color: var(--pw-muted); cursor: pointer; } +.personal-goal-tree-toggle:hover { color: var(--pw-text); } +.personal-goal-root-scope { display: flex; align-items: center; gap: 7px; margin: 0 10px 8px; border: 0; background: transparent; color: var(--pw-muted); cursor: pointer; font: inherit; } .personal-manager-link, .personal-goal-link, .personal-sidebar-utility { width: 100%; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; } .personal-manager-link { display: grid; grid-template-columns: 30px 1fr auto auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 11px; font-weight: 600; } .personal-manager-link:hover, .personal-goal-link:hover { background: rgb(255 255 255 / 65%); } @@ -118,6 +121,8 @@ .personal-goal-list { display: grid; gap: 2px; } .personal-goal-row { display: grid; grid-template-columns: minmax(0, 1fr) 28px 28px; align-items: center; gap: 2px; border-radius: 11px; } .personal-goal-row:has(.personal-goal-move-actions) { grid-template-columns: minmax(0, 1fr) 52px 28px; } +.personal-goal-row.has-goal-tree { grid-template-columns: 22px minmax(0, 1fr) auto auto; } +.personal-goal-row.has-goal-tree:has(.personal-goal-move-actions) { grid-template-columns: 22px minmax(0, 1fr) 52px 28px; } .personal-goal-row[data-reorder-goal] > .personal-goal-link { cursor: grab; user-select: none; } .personal-goal-row[data-reorder-goal] > .personal-goal-link:active { cursor: grabbing; } .personal-goal-row.is-drop-before { box-shadow: 0 -2px var(--color-link, #0070f3); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts b/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts index 92127bcd26..879c07336c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts @@ -3,7 +3,7 @@ import { decodeGoalOrder, goalOrderStorageKey, moveGoal, orderedGoals } from "./ import type { WorkspaceGoal } from "./personal-workspace-model"; // The sidebar is keyed by source, so neither gestures nor preferences cross machines. -export function useGoalOrder(goals: WorkspaceGoal[], source: string) { +export function useGoalOrder(goals: WorkspaceGoal[], source: string, canMove = (_from: string, _to: string) => true) { const key = goalOrderStorageKey(source); const [order, setOrder] = useState(() => { try { return decodeGoalOrder(localStorage.getItem(key)); } catch { return []; } @@ -68,6 +68,7 @@ export function useGoalOrder(goals: WorkspaceGoal[], source: string) { const sorted = orderedGoals(goals, order); const visible = sorted.map((goal) => goal.goalId); function move(from: string, to: string, after: boolean) { + if (!canMove(from, to)) return; const current = orderRef.current; const next = moveGoal(current, visible, from, to, after); if (next === current) return; @@ -85,7 +86,8 @@ export function useGoalOrder(goals: WorkspaceGoal[], source: string) { return { sorted, target, saveFailed, lastMoved, move, moveBy(id: string, delta: -1 | 1) { - const neighbor = sorted[visible.indexOf(id) + delta]; + const siblings = sorted.filter(goal => canMove(id, goal.goalId)); + const neighbor = siblings[siblings.findIndex(goal => goal.goalId === id) + delta]; if (neighbor) move(id, neighbor.goalId, delta === 1); }, pointerProps: (id: string) => ({ @@ -105,7 +107,7 @@ export function useGoalOrder(goals: WorkspaceGoal[], source: string) { const row = document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-reorder-goal]"); const list = event.currentTarget.closest(".personal-goal-list"); const targetId = row?.dataset.reorderGoal; - if (!row || !targetId || !list?.contains(row) || targetId === current.id) { setTarget(null); return; } + if (!row || !targetId || !list?.contains(row) || targetId === current.id || !canMove(current.id, targetId)) { setTarget(null); return; } const bounds = row.getBoundingClientRect(); setTarget({ id: targetId, after: event.clientY > bounds.top + bounds.height / 2 }); }, diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index 6afc09644f..f3216ecc41 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -24,6 +24,20 @@ function RouteLoading() { const searchSchema = z.object({ goalId: z.string().optional().default(""), + // Absence alone opens all Goals. Preserve present decoded values, including + // empty strings/arrays, so malformed scope cannot become an unscoped visit. + // Numeric opaque IDs still survive the router's JSON decoding. + rootGoalId: z.unknown().transform(value => typeof value === "string" && value + ? value : JSON.stringify(value)).optional(), + // Preserve the existing history/analysis owners' query state. They validate + // it from the URL themselves; router decoding can produce arrays or numbers. + research_lens: z.unknown().optional(), + research_goal: z.unknown().optional(), + research_node: z.unknown().optional(), + research_group: z.unknown().optional(), + research_pack: z.unknown().optional(), + research_analysis: z.unknown().optional(), + research_analysis_goal: z.unknown().optional(), // Stable Goal/Agent working conversation; empty string opens the directory. workAgent: z.string().regex(/^[A-Za-z0-9._:-]{0,200}$/).optional().catch(undefined), todo: z.string().regex(/^[A-Za-z0-9._:-]{1,200}$/).optional().catch(undefined), diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index bbdcc13ee7..e9071b946e 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -4,6 +4,7 @@ import { conversationReturnSessions, conversationPendingReturnSessions, reconcil import { readConversationReturns, readTaskSessions } from "../data/conversation-return-observation"; import { currentChannelSession, useConversationHistory } from "../data/use-conversation-history"; import { useChatProjects } from "../data/use-chat-projects"; +import { goalSubtree } from "../features/personal-workspace/goal-hierarchy"; import {compactWorkspaceText as compactShareText, workspaceAgentTodoFromItem} from "../features/personal-workspace/personal-workspace-model"; import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation"; import { attentionDetails, attentionDetailsFromSnapshot, sourceAttention } from "../features/personal-workspace/attention-details"; @@ -451,6 +452,7 @@ function buildAgentManagementRows( type PersonalGoalState = "需修复" | "等你" | "等待条件" | "已安排" | "已完成" | "安静运行" | "已停止"; type PersonalGoalItem = { + parentGoalId?: string | null; acceptanceObservation?: GoalAcceptanceObservation | null; loadState?: "loading" | "error"; loadError?: WorkspaceLoadError; @@ -1089,6 +1091,7 @@ function buildPersonalHomeModel( } : undefined, acceptanceObservation: goal.acceptance_observation, goalId: goal.id, + parentGoalId: goal.parent_goal_id, latestActivity: row.latestRun?.generated_at ?? "", needsYou, needsYouActionKind: needsYouTodo?.actionKind ?? null, @@ -1229,8 +1232,10 @@ function PersonalGoalHome({ payload, progress, rows, - selectedGoalId, - selectedWorkspaceRef, + selectedGoalId: requestedGoalId, + selectedWorkspaceRef: requestedWorkspaceRef, + rootGoalId, + onChangeGoalRoot, statusSourceControl, theme, }: { @@ -1248,6 +1253,8 @@ function PersonalGoalHome({ onSelectGoal: (goalId: string, view?: WorkspaceGoalTab) => void; onSelectWorkspace: (projectRef: string | null) => void; selectedWorkspaceRef: string | null; + rootGoalId?: string; + onChangeGoalRoot: (goalId: string | null) => void; onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh: (scope?: WorkspaceReadScope) => Promise; onBackgroundRefresh?: () => void | Promise; @@ -1259,7 +1266,8 @@ function PersonalGoalHome({ statusSourceControl: StatusSourceControl; theme: "light" | "dark"; }) { - const readOnly = statusSourceControl.activeSource.readOnly; + const readOnly = statusSourceControl.activeSource.readOnly + || Boolean(rootGoalId && !rows.some(row => row.goal.id === rootGoalId)); const remoteGoalLifecycleHost = statusSourceControl.activeSource.kind === "ssh_tunnel" ? statusSourceControl.activeSource.hostAlias : undefined; @@ -1314,23 +1322,41 @@ function PersonalGoalHome({ }, }; }, [payload, rows, progress, goalSubagentConfigurationEnabled, t]); + const scopedModel = useMemo(() => { + if (!rootGoalId) return loadedModel; + const goals = goalSubtree(loadedModel.goals, rootGoalId); + const included = new Set(goals.map(goal => goal.goalId)); + const userTodos = loadedModel.userTodos.filter(item => included.has(item.goalId)); + return { + ...loadedModel, goals, userTodos, visibleUserTodos: userTodos.slice(0, 5), onboarding: undefined, + // These count projected owner-attention rows, never parent completion or + // an aggregate benchmark score. Each Goal retains its own acceptance. + openUserTodoCount: userTodos.length, blockingTodoCount: userTodos.filter(item => item.blocking).length, + attentionHistory: loadedModel.attentionHistory?.filter(item => included.has(item.goalId)), + goalNotifications: loadedModel.goalNotifications?.filter(item => included.has(item.goalId)), + workers: loadedModel.workers?.filter(item => item.currentTodoGoalId && included.has(item.currentTodoGoalId)), + }; + }, [loadedModel, rootGoalId]); // A retained payload is not a current fact once the source's latest read // failed: the first-Goal invitation is withdrawn and the failure is shown // with a retry, instead of an empty workspace that claims to be updated. const model = useMemo(() => { - if (!sourceError) return loadedModel; + if (!sourceError) return scopedModel; return { - ...loadedModel, + ...scopedModel, onboarding: undefined, systemHealth: { ok: false, retryable: true, summary: t("home.health.sourceUnreadable"), issues: [t("home.health.sourceUnreadableDetail", { error: sourceError })], freshnessWarning: null, - diagnostics: loadedModel.systemHealth?.diagnostics ?? [], + diagnostics: scopedModel.systemHealth?.diagnostics ?? [], }, }; - }, [sourceError, loadedModel, t]); + }, [sourceError, scopedModel, t]); + const selectedGoalId = rootGoalId && !model.goals.some(goal => goal.goalId === requestedGoalId) + ? rootGoalId : requestedGoalId; + const selectedWorkspaceRef = rootGoalId ? null : requestedWorkspaceRef; // A workspace conversation has no Goal, so no Goal projection may apply to it. const selectedGoal = selectedWorkspaceRef ? null : model.goals.find((goal) => goal.goalId === selectedGoalId) ?? null; const workspaceProjects = useChatProjects(readOnly); @@ -3115,6 +3141,8 @@ function PersonalGoalHome({ conversationSupportsSteering={steeringSessionIds.has(runtimeBindings[contextId]?.sessionId ?? "")} conversationHistoryState={conversationHistory} model={workspaceModel} + rootGoalId={rootGoalId} + onChangeGoalRoot={onChangeGoalRoot} researchApi={researchUrls?.indexUrl && researchUrls.detailUrl ? { indexUrl: researchUrls.indexUrl, detailUrl: researchUrls.detailUrl, goals: selectedPayload.run_history.goals, @@ -3125,7 +3153,9 @@ function PersonalGoalHome({ selectedAgentModelReadback={agentModelReadbacks[agentModelSelectionKey(contextId, selectedAgent.agentId)]} selectedAgentReasoningEffort={selectedAgentEfforts[agentModelSelectionKey(contextId, selectedAgent.agentId)]} selectedGoalId={selectedGoal?.goalId ?? null} - serviceNotice={executionDiscoveryError + serviceNotice={rootGoalId && !model.goals.some(goal => goal.goalId === rootGoalId) + ?

{t("sidebar.rootUnavailable")}

+ : executionDiscoveryError ?

{t(executionDiscoveryError === "partial" ? "runs.discoveryPartial" : "runs.discoveryOffline")}

: null} statusSourceControl={statusSourceControl && executionDiscoveryError === "offline" && statusSourceControl.connectionState === "connected" @@ -3612,6 +3642,9 @@ export function DashboardPage() { return ( void navigate({search: current => ({...current, + rootGoalId: rootGoalId ?? undefined, goalId: rootGoalId ?? ("goalId" in current ? current.goalId : ""), workspace: undefined})})} goalArchiveLoadState={goalArchiveLoadState} sourceError={loadError} selectedTodoId={search.todo ?? null} diff --git a/docs/product/use-cases/auto-research/long-horizon-progress-design.md b/docs/product/use-cases/auto-research/long-horizon-progress-design.md index 6e889b894e..52a6a60d67 100644 --- a/docs/product/use-cases/auto-research/long-horizon-progress-design.md +++ b/docs/product/use-cases/auto-research/long-horizon-progress-design.md @@ -296,6 +296,23 @@ thresholds, automatic interventions or new experiment authorization. ## Source, access and recovery +Research and experiments use the existing Goal workspace. Within one status +source, the registry's declared `parent_goal_id` organizes a recursive sidebar: +children start folded and the selected Goal's ancestors reopen on reload or +browser Back. Goal ordering moves siblings only; it never changes parentage. +Orphans and cyclic ancestry remain visible with an unresolved-parent indicator. + +Selecting a parent exposes **Only this Goal and its children**. It opens the same +frontend with a shareable `rootGoalId` display scope; **All Goals** returns to the +current source's full workspace. A second frontend window can open that URL +without copying, moving or rebinding live Goals. An unavailable root shows a +retryable gap, without substituting unrelated Goals. The grouping conveys no +Agent assignment, dependency, access, budget or lifecycle inheritance; each +Goal retains its own execution state and acceptance. Child counts do not settle +the parent's outcome. This same-source view does not itself connect experiment +instances held in other registries or runtimes; their source-qualified read +bridge remains a separate integration boundary. + An inline evidence detail needs the producing owner, source identity/revision, artifact version, observation/check time, freshness/coverage, explicit relationship basis and separate check/adoption observations. Inventory existing producers before diff --git a/docs/product/use-cases/auto-research/long-horizon-progress-design.zh-CN.md b/docs/product/use-cases/auto-research/long-horizon-progress-design.zh-CN.md index f1a36645f7..5bbc748ed3 100644 --- a/docs/product/use-cases/auto-research/long-horizon-progress-design.zh-CN.md +++ b/docs/product/use-cases/auto-research/long-horizon-progress-design.zh-CN.md @@ -129,6 +129,10 @@ flowchart LR ## 来源、访问与恢复 +研究与实验复用现有 Goal 工作台。同一状态来源内,注册表声明的 `parent_goal_id` 组织递归侧栏:子 Goal 默认折叠,刷新或浏览器返回时展开当前所选 Goal 的祖先路径。排序只移动同级 Goal,不改变父子关系。缺少父项或存在环的 Goal 仍可见,并标明父关系未解决。 + +选中父 Goal 后可选择**只看此 Goal 及子 Goal**:仍使用同一前端,`rootGoalId` 形成可分享的展示范围;**全部 Goals**返回当前来源的完整工作台。另一前端窗口可直接打开该 URL,无需复制、迁移或重新绑定真实 Goal。根项不可用时提供重试,不能换成无关 Goal。组织关系不继承 Agent 分工、依赖、权限、预算或生命周期,每个 Goal 保留自身执行状态与验收,子项数量不能结算父项成果。此同源视图本身不会接入其他注册表或 runtime 中的实验实例;带来源身份的读桥仍是独立整合边界。 + 行内证据详情需要生产 owner、来源身份/版本、产物版本、观测/检查时间、新鲜度/覆盖度、显式关系依据以及分离的检查/采用观察。新增字段前先盘点既有 producer;有真实调用方需要扩展时复用共享 typed presentation owner。Python 可适配获准观察,不能另造评估或进展 authority。 | 情形 | 必须怎样呈现 | diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 7566b72568..ed3688b6fa 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -19,6 +19,7 @@ import {goalSuggestionsLiveScenario} from "./personal-workspace-browser/goal-sug import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs"; +import {goalHierarchyScenario} from "./personal-workspace-browser/goal-hierarchy.mjs"; import {configurationBackupScenario} from "./personal-workspace-browser/configuration-backup.mjs"; import {attentionUserActionScenario} from "./personal-workspace-browser/attention-user-action.mjs"; import {prReviewAgentOrderScenario} from "./personal-workspace-browser/pr-review-agent-order.mjs"; @@ -109,6 +110,7 @@ scenarioCatalog.push(performanceDiagnosisScenario); scenarioCatalog.push(blockedNoticeSettingsScenario); scenarioCatalog.push(nativeChildActivityScenario); scenarioCatalog.push(privateStewardScopeScenario); +scenarioCatalog.push(goalHierarchyScenario); scenarioCatalog.push(externalEvidenceReadbackScenario); scenarioCatalog.push(configurationBackupScenario); scenarioCatalog.push(exploreCompositionOwnerScenario); diff --git a/examples/personal-workspace-browser/goal-hierarchy.mjs b/examples/personal-workspace-browser/goal-hierarchy.mjs new file mode 100644 index 0000000000..e75b910e41 --- /dev/null +++ b/examples/personal-workspace-browser/goal-hierarchy.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import {mkdtemp, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join, resolve} from "node:path"; +import {outputDir} from "./fixture.mjs"; +import {startHistoryOwner} from "./research-history.mjs"; +import {fetchWorkspaceBackend, openWorkspacePage} from "./scenario-context.mjs"; + +export const goalHierarchyScenario = { + id: "goal-hierarchy", + async run({browser, collectCoverage, url}) { + const root = await mkdtemp(join(tmpdir(), "loopx-goal-tree-")); + let owner, context, denied = false; + const captures = [ + ["research", "Research · synthetic"], + ["portfolio", "Portfolio", "research"], + ["calibration", "Calibration", "portfolio"], + ["graph", "Graph", "research"], + ["daily", "Daily work"], + ["orphan", "Unresolved parent", "not-readable"], + ["cycle-a", "Cycle A", "cycle-b"], + ["cycle-b", "Cycle B", "cycle-a"], + ]; + try { + const paths = await Promise.all(captures.map(async ([goal_id, display_name, parent_goal_id]) => { + const path = join(root, `${goal_id}.json`); + await writeFile(path, JSON.stringify({goal_id, display_name, parent_goal_id, events: []})); + return path; + })); + owner = await startHistoryOwner(paths); + const directory = await (await fetch(`${owner.base}/status.json?view=workspace-directory`)).json(); + assert.equal(directory.goals.find(g => g.id === "portfolio").parent_goal_id, "research"); + const entry = new URL(url); + const researchFocus = {research_lens: "graph", research_goal: "calibration", research_node: JSON.stringify(["source-one", "source-two"]), + research_pack: "a".repeat(64), research_analysis_goal: "calibration"}; + for (const [key, value] of Object.entries(researchFocus)) entry.searchParams.set(key, value); + context = await openWorkspacePage(browser, entry.href, {collectCoverage, beforeGoto: async (_api, page) => { + await page.route(target => target.pathname === "/status.json", async route => { + if (denied && new URL(route.request().url()).searchParams.get("goal_id") === "graph") { + return route.fulfill({status: 403, json: {ok: false, error_code: "workspace_status_access_denied"}}); + } + const response = await fetchWorkspaceBackend(route, owner.base); + await route.fulfill({response}); + }); + }}); + const {page, api} = context; + const row = id => page.locator(`[data-reorder-goal="${id}"]`); + const assertResearchFocus = () => { + const query = new URL(page.url()).searchParams; + for (const [key, value] of Object.entries(researchFocus)) assert.equal(query.get(key), value, `${key} survives workspace navigation`); + }; + const assertTreeLayout = async () => { + const bounds = await page.locator(".personal-goal-row").evaluateAll(rows => rows.map(row => { + const link = row.querySelector(".personal-goal-link").getBoundingClientRect(); + const control = row.querySelector(".personal-goal-lifecycle")?.getBoundingClientRect(); + return {id: row.dataset.reorderGoal, x: link.x, top: link.top, bottom: link.bottom, control}; + })); + for (const row of bounds) if (row.control) assert(row.control.top >= row.top && row.control.bottom <= row.bottom, + `${row.id}: lifecycle controls stay alongside the Goal on narrow screens`); + assert.equal(bounds.find(row => row.id === "portfolio").x, bounds.find(row => row.id === "graph").x, "Siblings share one indentation"); + assert(bounds.find(row => row.id === "calibration").x > bounds.find(row => row.id === "portfolio").x, "A deeper Goal is visibly nested"); + }; + await row("research").waitFor(); + assert.equal(await row("portfolio").count(), 0, "Children start folded on the ordinary workspace"); + for (const id of ["daily", "orphan", "cycle-a", "cycle-b"]) await row(id).waitFor(); + await page.screenshot({path: resolve(outputDir, "goal-hierarchy-overview.png"), animations: "disabled"}); + await row("research").locator(".personal-goal-link").click(); + await page.getByRole("button", {name: "只看此 Goal 及子 Goal", exact: true}).click(); + await page.waitForURL(target => target.searchParams.get("rootGoalId") === "research"); + assertResearchFocus(); + await row("portfolio").waitFor(); + for (const id of ["daily", "orphan", "cycle-a", "cycle-b"]) assert.equal(await row(id).count(), 0); + assert.equal(await page.locator(".personal-manager-link").count(), 0); + await row("portfolio").getByRole("button", {name: "展开子 Goal:Portfolio", exact: true}).focus(); + await page.keyboard.press("Enter"); + await row("calibration").locator(".personal-goal-link").click(); + await page.reload({waitUntil: "networkidle"}); + await row("calibration").locator('[aria-current="page"]').waitFor(); + assert.equal(new URL(page.url()).searchParams.get("rootGoalId"), "research"); + assertResearchFocus(); + await assertTreeLayout(); + await page.screenshot({path: resolve(outputDir, "goal-hierarchy-focused.png"), animations: "disabled"}); + await page.getByRole("button", {name: "调整 Goal 顺序", exact: true}).click(); + assert(await page.getByRole("button", {name: "上移 Calibration", exact: true}).isDisabled(), "A singleton cannot move into its parent's siblings"); + assert(await page.getByRole("button", {name: "下移 Calibration", exact: true}).isDisabled()); + await page.getByRole("button", {name: "上移 Graph", exact: true}).click(); + assert.deepEqual(await page.locator("[data-reorder-goal]").evaluateAll(rows => rows.map(row => row.dataset.reorderGoal)), + ["research", "graph", "portfolio", "calibration"], "Sorting moves only siblings and preserves the subtree"); + await page.getByRole("button", {name: "调整 Goal 顺序", exact: true}).click(); + await page.getByRole("button", {name: "全部 Goals", exact: true}).click(); + await row("daily").waitFor(); + await page.goBack({waitUntil: "networkidle"}); + await row("calibration").locator('[aria-current="page"]').waitFor(); + assert.equal(await row("daily").count(), 0, "Back restores the root scope and selected child"); + assertResearchFocus(); + denied = true; + await page.getByRole("button", {name: "刷新状态", exact: true}).click(); + await row("graph").and(page.locator("[data-load-error]")).waitFor(); + assert.equal(await row("daily").count(), 0, "A denied child does not widen the display scope"); + denied = false; + await page.getByRole("button", {name: "刷新状态", exact: true}).click(); + await row("graph").and(page.locator("[data-load-error]")).waitFor({state: "hidden"}); + await row("graph").waitFor(); + await page.setViewportSize({width: 390, height: 844}); + await page.reload({waitUntil: "networkidle"}); + await page.getByRole("button", {name: "打开 Goal 导航", exact: true}).click(); + await row("calibration").locator('[aria-current="page"]').waitFor(); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await assertTreeLayout(); + await page.screenshot({path: resolve(outputDir, "goal-hierarchy-mobile.png"), animations: "disabled"}); + const unavailable = new URL(page.url()); + unavailable.searchParams.set("rootGoalId", "missing"); + unavailable.searchParams.set("goalId", "daily"); + await page.goto(unavailable.href, {waitUntil: "networkidle"}); + await page.getByRole("alert").filter({hasText: "当前来源中此 Goal 范围不可用"}).waitFor(); + assert.equal(await page.locator(".personal-goal-row").count(), 0); + assert.equal(await page.getByTestId("personal-first-goal").count(), 0); + assert.equal(await page.locator(".personal-manager-greeting, .personal-composer-wrap").count(), 0, + "An unavailable research root must not open an unrelated steward conversation"); + unavailable.searchParams.set("rootGoalId", "../missing"); + await page.goto(unavailable.href, {waitUntil: "networkidle"}); + await page.getByRole("alert").filter({hasText: "当前来源中此 Goal 范围不可用"}).waitFor(); + assert.equal(await page.locator(".personal-goal-row").count(), 0, "An invalid scope cannot silently reopen all Goals"); + await page.setViewportSize({width: 1512, height: 982}); + for (const invalid of ["[]", '[""]', '["research"]', "{}", ""]) { + unavailable.searchParams.set("rootGoalId", invalid); + await page.goto(unavailable.href, {waitUntil: "networkidle"}); + await page.getByRole("alert").filter({hasText: "当前来源中此 Goal 范围不可用"}).waitFor(); + assert.equal(await page.locator(".personal-goal-row").count(), 0, `${invalid}: decoded values cannot widen the scope`); + assert.equal(await page.locator(".personal-manager-greeting, .personal-composer-wrap").count(), 0); + await page.getByRole("button", {name: "全部 Goals", exact: true}).click(); + await row("daily").waitFor(); + await page.goBack({waitUntil: "networkidle"}); + await page.getByRole("alert").filter({hasText: "当前来源中此 Goal 范围不可用"}).waitFor(); + assert.equal(await page.locator(".personal-goal-row").count(), 0, "Back preserves an explicitly invalid scope"); + } + assert.equal(api.turnRequests.length, 0); + assert.equal(api.actionApplies.length, 0); + const readback = await (await fetch(`${owner.base}/fixture/readback`)).json(); + assert.equal(readback.source_unchanged, true); + assert.equal(readback.mutation_attempts, 0); + const errors = context.errors.filter(error => !error.includes("403 (Forbidden)")); + assert.deepEqual(errors, []); + return {coverageEntries: await context.close(), note: "Real directory/status parent transport; folded recursive navigation, root-scoped existing workspace, reload/Back, denied-child recovery, orphan/cycle retention, narrow keyboard navigation; zero Turns/actions/source mutation"}; + } finally { + await context?.close(); + await owner?.close(); + await rm(root, {recursive: true, force: true}); + } + }, +}; diff --git a/examples/personal-workspace-browser/research-history-backend.py b/examples/personal-workspace-browser/research-history-backend.py index 44968a9e40..28a8eacf9a 100644 --- a/examples/personal-workspace-browser/research-history-backend.py +++ b/examples/personal-workspace-browser/research-history-backend.py @@ -56,6 +56,7 @@ def main(): state = root / f"{captured_goal}.md" state.write_text("# Read-only captured research history\n\n## Agent Todo\n\n## Completed Work Archive\n") goals.append({"id": captured_goal, + **({"parent_goal_id": capture["parent_goal_id"]} if capture.get("parent_goal_id") else {}), "display_name": capture.get("display_name", "Captured research history"), "repo": str(root), "state_file": state.name, "domain": "project-goal-control-plane", diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 8a28bbf67e..a13b56fa1f 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -56,8 +56,9 @@ function analysisReadback(selected = "a") { return snapshot; } -async function startHistoryOwner(fixture) { - const child = spawn(resolveTestPython(), ["-u", "examples/personal-workspace-browser/research-history-backend.py", ...(fixture ? ["--fixture", fixture] : [])], +export async function startHistoryOwner(fixture) { + const fixtures = Array.isArray(fixture) ? fixture : fixture ? [fixture] : []; + const child = spawn(resolveTestPython(), ["-u", "examples/personal-workspace-browser/research-history-backend.py", ...fixtures.flatMap(path => ["--fixture", path])], {cwd: repoRoot, env: {...process.env, LOOPX_USAGE_PING: "0"}, stdio: ["ignore", "pipe", "pipe"]}); let diagnostic = ""; child.stderr.on("data", chunk => {diagnostic = (diagnostic + chunk).slice(-4000);}); diff --git a/loopx/chat_workspace_directory.py b/loopx/chat_workspace_directory.py index e845c9866c..74358ba6f9 100644 --- a/loopx/chat_workspace_directory.py +++ b/loopx/chat_workspace_directory.py @@ -8,6 +8,7 @@ from .control_plane.goals.activation import goal_activation_state from .control_plane.status.collection import registry_activation_revision from .registry import registry_goals +from .control_plane.presentation.goal_metadata import goal_parent_fields def workspace_goal_directory( @@ -24,6 +25,7 @@ def workspace_goal_directory( goals.append( { "id": goal_id, + **goal_parent_fields(goal), "display_name": title[:200], "activation_state": goal_activation_state(goal).value, "registry_member": True, diff --git a/loopx/control_plane/presentation/goal_metadata.py b/loopx/control_plane/presentation/goal_metadata.py new file mode 100644 index 0000000000..f2e44a861b --- /dev/null +++ b/loopx/control_plane/presentation/goal_metadata.py @@ -0,0 +1,14 @@ +"""Read-only transport of existing registry organization, without authority inference.""" + +from collections.abc import Mapping + +from ...public_safe_text import OPAQUE_ID_PATTERN + + +def goal_parent_fields(goal: Mapping[str, object]) -> dict[str, str]: + parent = goal.get("parent_goal_id") + # Malformed metadata must not expose a local path or become an executable + # route. Absence keeps the legacy flat projection byte-for-byte unchanged. + if isinstance(parent, str) and OPAQUE_ID_PATTERN.fullmatch(parent): + return {"parent_goal_id": parent} + return {} diff --git a/loopx/control_plane/runtime/run_history.py b/loopx/control_plane/runtime/run_history.py index ba441ff946..b35a144789 100644 --- a/loopx/control_plane/runtime/run_history.py +++ b/loopx/control_plane/runtime/run_history.py @@ -4,6 +4,7 @@ from ...boundary_authority import checkpointed_boundary_authority_summary from ...orchestration import compact_orchestration_policy +from ..presentation.goal_metadata import goal_parent_fields from .run_context_retention import ( compact_goal_semantic_history, latest_runs_with_agent_context, @@ -92,6 +93,7 @@ def build_run_history( goals.append( { "id": goal.get("id"), + **goal_parent_fields(goal), "activation_state": goal.get("activation_state") or "active", "registry_goal_digest": goal.get("registry_goal_digest"), "display_name": goal.get("display_name"), diff --git a/loopx/history.py b/loopx/history.py index ce19d489b2..6d3c264d95 100644 --- a/loopx/history.py +++ b/loopx/history.py @@ -11,6 +11,7 @@ from typing import Any from .file_lock import exclusive_run_index_lock +from .control_plane.presentation.goal_metadata import goal_parent_fields from .authority import goal_authority_registry_summary from .control_plane import compact_control_plane_policy from .control_plane.goals.activation import ( @@ -454,6 +455,7 @@ def collect_history( quota = goal_quota_with_spend_ledger(meta, goal_runs) if registry_member else None goal_record = { "id": current_goal_id, + **goal_parent_fields(meta), "activation_state": activation_state.value, "registry_goal_digest": registry_goal_digests.get(current_goal_id), "display_name": meta.get("display_name") if registry_member else None, diff --git a/tests/presentation/test_goal_hierarchy.py b/tests/presentation/test_goal_hierarchy.py new file mode 100644 index 0000000000..35691d6ea2 --- /dev/null +++ b/tests/presentation/test_goal_hierarchy.py @@ -0,0 +1,8 @@ +from loopx.control_plane.presentation.goal_metadata import goal_parent_fields + + +def test_organization_is_optional_and_never_publishes_a_path_or_object(): + for value in (None, "", "/private/fixture/parent", "../parent", {"id": "parent"}): + assert goal_parent_fields({"parent_goal_id": value}) == {} + assert goal_parent_fields({"claimed_by": "parent", "goal_id": "parent.child"}) == {} + assert goal_parent_fields({"parent_goal_id": "parent"}) == {"parent_goal_id": "parent"} diff --git a/tests/presentation/test_goal_objective.py b/tests/presentation/test_goal_objective.py index e8e7d322e2..7a023ae9bf 100644 --- a/tests/presentation/test_goal_objective.py +++ b/tests/presentation/test_goal_objective.py @@ -33,6 +33,7 @@ def objective_source(tmp_path, monkeypatch, request): runtime = tmp_path / "runtime" goal = { "id": "research", + "parent_goal_id": "research-root", "display_name": "A different display title", "repo": str(tmp_path), "state_file": "state.md", @@ -101,10 +102,18 @@ def test_real_status_http_keeps_an_empty_goals_declared_objective(objective_sour payload = json.loads(response.read()) connection.close() assert response.status == 200, payload + assert payload["run_history"]["goals"][0]["parent_goal_id"] == "research-root" assert ( payload["run_history"]["goals"][0]["goal_objective"] == "Answer the original research question." ) + connection = http.client.HTTPConnection(*server.server_address, timeout=45) + connection.request("GET", "/status.json?view=workspace-directory") + response = connection.getresponse() + directory = json.loads(response.read()) + connection.close() + assert response.status == 200, directory + assert directory["goals"][0]["parent_goal_id"] == "research-root" assert (registry.read_bytes(), state.read_bytes()) == before finally: server.shutdown()