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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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]];
}));
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]);
});
Original file line number Diff line number Diff line change
@@ -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<T extends GoalParent>(goals: readonly T[]) {
const byId = new Map(goals.map(goal => [goal.goalId, goal]));
const parents = new Map<string, string>();
const invalidParents = new Set<string>();
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<string>();
for (const goal of goals) {
const path: string[] = [];
const positions = new Map<string, number>();
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<string | null, T[]>();
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<T extends GoalParent>(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<T extends GoalParent>(goals: readonly T[], expanded: ReadonlySet<string>) {
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;
}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -32,6 +33,8 @@ export function GoalSidebar({
onRequestGoalLifecycle,
onSelectGoal,
selectedGoalId,
rootGoalId,
onChangeGoalRoot,
statusSourceControl,
}: {
attentionCount: number;
Expand All @@ -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<string | null, WorkspaceGoal[]>();
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<Set<string>>(() => 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) => (
<div className={`personal-goal-row${ordering.target?.id === goal.goalId ? ordering.target.after ? " is-drop-after" : " is-drop-before" : ""}`} key={goal.goalId} data-reorder-goal={stopped ? undefined : goal.goalId} data-load-error={goal.loadError}>
const goalRow = (goal: WorkspaceGoal, stopped: boolean, depth = 0, hasChildren = false) => (
<div className={`personal-goal-row${hasTree ? " has-goal-tree" : ""}${ordering.target?.id === goal.goalId ? ordering.target.after ? " is-drop-after" : " is-drop-before" : ""}`} key={goal.goalId} data-reorder-goal={stopped ? undefined : goal.goalId} data-load-error={goal.loadError} data-goal-depth={depth} style={depth ? {paddingInlineStart: Math.min(depth, 4) * 10} : undefined}>
{hasChildren ? <button type="button" className="personal-goal-tree-toggle" aria-expanded={expanded.has(goal.goalId)} aria-label={t(expanded.has(goal.goalId) ? "sidebar.collapseChildren" : "sidebar.expandChildren", {goal: goal.title})} onClick={() => toggle(goal.goalId)}>{expanded.has(goal.goalId) ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</button>
: hasTree ? <span className="personal-goal-tree-spacer" aria-hidden /> : null}
<button
{...(!stopped ? ordering.pointerProps(goal.goalId) : {})}
title={stopped ? undefined : t("sidebar.dragGoal")}
Expand All @@ -69,13 +100,14 @@ export function GoalSidebar({
<GoalIdentityMark goal={goal} />
<span className="personal-goal-link-copy">
<strong title={goal.title}>{goal.title}</strong>
{tree.parents.has(goal.goalId) ? <span className="personal-sr-only">{t("sidebar.childOf", {goal: tree.byId.get(tree.parents.get(goal.goalId)!)!.title})}</span> : null}
<GoalRowActivity goal={goal} showLoad={!stopped || selectedGoalId === goal.goalId} />
</span>
<ChevronRight size={15} />
{tree.invalidParents.has(goal.goalId) && goal.goalId !== rootGoalId ? <AlertTriangle size={14} aria-label={t("sidebar.invalidParent")}><title>{t("sidebar.invalidParent")}</title></AlertTriangle> : <ChevronRight size={15} />}
</button>
{!stopped && sorting ? <div className="personal-goal-move-actions">
<button type="button" aria-label={t("sidebar.moveUp", { goal: goal.title })} disabled={activeGoals[0]?.goalId === goal.goalId} onClick={() => ordering.moveBy(goal.goalId, -1)}><ArrowUp aria-hidden="true" size={13} /></button>
<button type="button" aria-label={t("sidebar.moveDown", { goal: goal.title })} disabled={activeGoals.at(-1)?.goalId === goal.goalId} onClick={() => ordering.moveBy(goal.goalId, 1)}><ArrowDown aria-hidden="true" size={13} /></button>
<button type="button" aria-label={t("sidebar.moveUp", { goal: goal.title })} disabled={siblingGroups.get(tree.parents.get(goal.goalId) ?? null)?.[0]?.goalId === goal.goalId} onClick={() => ordering.moveBy(goal.goalId, -1)}><ArrowUp aria-hidden="true" size={13} /></button>
<button type="button" aria-label={t("sidebar.moveDown", { goal: goal.title })} disabled={siblingGroups.get(tree.parents.get(goal.goalId) ?? null)?.at(-1)?.goalId === goal.goalId} onClick={() => ordering.moveBy(goal.goalId, 1)}><ArrowDown aria-hidden="true" size={13} /></button>
</div> : null}
{onRequestGoalLifecycle && lifecycleOperationEnabled(stopped ? "resume" : "stop") ? (
<>
Expand Down Expand Up @@ -117,7 +149,7 @@ export function GoalSidebar({
{statusSourceControl ? <StatusSourceSwitcher {...statusSourceControl} /> : null}

<nav aria-label={t("home.workspace")} className="personal-sidebar-nav">
<button
{!rootGoalId ? <button
aria-current={selectedGoalId === null ? "page" : undefined}
className="personal-manager-link"
onClick={() => onSelectGoal(null)}
Expand All @@ -127,16 +159,17 @@ export function GoalSidebar({
<span>{t("sidebar.manager")}</span>
{attentionCount > 0 ? <span className="personal-sidebar-count">{attentionCount}</span> : null}
<ChevronRight size={15} />
</button>
</button> : null}

<div className="personal-sidebar-section-title">
<span>Goals</span>
<span className="personal-sidebar-title-actions"><small>{activeGoals.length}</small><button aria-label={t("sidebar.sortGoals")} title={t("sidebar.sortGoals")} aria-pressed={sorting} onClick={() => setSorting(!sorting)} type="button"><ArrowUpDown aria-hidden="true" size={15} /></button>{onRequestGoalCreate ? <button aria-label={t("sidebar.createGoal")} onClick={onRequestGoalCreate} type="button"><Plus size={15} /></button> : null}</span>
</div>
{onChangeGoalRoot && (rootGoalId || (selectedGoalId && tree.children.has(selectedGoalId))) ? <button className="personal-goal-root-scope" type="button" onClick={() => onChangeGoalRoot(rootGoalId ? null : selectedGoalId)} title={t("sidebar.scopeHelp")}><Focus size={14} />{t(rootGoalId ? "sidebar.allGoals" : "sidebar.focusGoal")}</button> : null}
{ordering.saveFailed ? <p role="status">{t("sidebar.orderNotSaved")}</p> : null}
<span className="personal-sr-only" role="status">{ordering.lastMoved ? t("sidebar.goalMoved", { goal: ordering.lastMoved.title, position: ordering.lastMoved.position }) : ""}</span>
<div className="personal-goal-list">
{activeGoals.map((goal) => goalRow(goal, false))}
{visibleGoalTree(activeGoals, expanded).map(({goal, depth, hasChildren}) => goalRow(goal, false, depth, hasChildren))}
</div>
{stoppedGoals.length || goalArchiveLoadState.phase === "loading" || goalArchiveLoadState.phase === "error" ? (
<details
Expand All @@ -157,7 +190,7 @@ export function GoalSidebar({
{onRetryGoalArchive ? <button onClick={onRetryGoalArchive} type="button">{t("sidebar.retryStopped")}</button> : null}
</div>
) : null}
{stoppedGoals.map((goal) => goalRow(goal, true))}
{visibleGoalTree(stoppedGoals, expanded).map(({goal, depth, hasChildren}) => goalRow(goal, true, depth, hasChildren))}
</div>
</details>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -2946,6 +2954,14 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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}",
Expand Down
Loading
Loading