diff --git a/CHANGELOG.md b/CHANGELOG.md
index 570fe34..d5608ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,94 @@ All notable changes to Code Context Control (C3) are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.57.0] - 2026-07-24
+
+### Added — Hub sub-project management overhaul (UI/UX)
+
+Manual parent/child control becomes a first-class Hub surface.
+
+- **First-child bootstrap fix**: "Designate sub-project…" now renders on every
+ non-child project card — previously it only appeared once a project already
+ HAD registered children, so the first child could only be created via
+ `c3 sub add` (the UI feature was invisible until the CLI had been used).
+- **Passive link health**: `GET /api/projects` now annotates parent rows with
+ `subproject_issues` (count of broken three-way links + registry orphans)
+ and child rows with `link_status` (`ok | backlink_broken | unregistered |
+ missing_folder | missing_c3 | orphan`) via a new
+ `_annotate_subproject_links` helper — config + filesystem + raw-registry
+ reads only, no port probing. Cards surface red "N link issues" badges on
+ parents and amber/red status badges on children without anyone having to
+ run Reconcile manually.
+- **Sub-projects drill tab** (`drill_subprojects.js`, new): dedicated
+ management surface — per-child rows (status, facts, alerts) with inline
+ Validate and Promote actions; Designate button (fixes bootstrap in the
+ drill too, with an educational empty state); inline Reconcile with
+ per-issue detail and planned repair actions; Cascade launcher with op
+ picker, include-parent toggle, affected-children preview, live progress,
+ Cancel, and 409 attach-to-running handling.
+- **"Make sub-project of…"** on eligible top-level cards: reverse designate —
+ lists only registered parents whose folder physically contains the project
+ (containment is mandatory), validates via the existing endpoint, links with
+ adopt semantics.
+- **Clear-mode de-init**: the child kebab's duplicate "Unlink" (same server op
+ as Promote) is replaced by "De-initialize…" — the previously CLI-only
+ `remove mode='clear'` (deletes `.c3`, uninstalls MCP config, removes
+ instruction docs, deregisters) behind a typed-name confirm dialog.
+- **Cascade UX**: progress toasts gained a cancel affordance (the
+ `/cascade/cancel` endpoint existed with zero UI); the card submenu gained
+ an "Include parent" toggle (`include_parent` was accepted server-side but
+ never sent).
+- **Hierarchy visibility**: child cards show an "↳ parent" chip (annotated in
+ `buildProjectTree`, path-derived fallback when the parent row is filtered
+ out or unregistered); the drill header shows an "under parent" chip; grid
+ view child rows — previously inert — now carry kebab menus and link-status
+ badges; removing a parent pre-warns in the confirm dialog that its N
+ children become top-level (was a post-hoc toast).
+- **Ctrl-K hierarchy** (`global_search.js`): child result groups render a
+ "parent › child" breadcrumb; a scope chip row (All / Top-level only /
+ per-parent "+ children") drives real server-side filtering through the
+ endpoint's existing `projects` param.
+- **FolderPicker polish** (`modals.js`): navigation fenced to the parent
+ subtree (breadcrumb segments above the parent render inert instead of
+ post-hoc validation failures); explicit adopt/re-link messaging for
+ `has_c3` / already-registered folders; "Instruction docs / IDE" picker
+ (`auto | claude | vscode | cursor | codex | antigravity`) wired to the
+ endpoint's existing `ide` param; adopt-vs-initialize expectation shown
+ before designating.
+- **Inheritance toggles**: the config editor gains a "Sub-projects" section on
+ parent projects — "Parent memory recall includes child facts"
+ (`memory_rollup`), "Search scope 'all' fans out to children"
+ (`search_fanout`), and "Max children per query", persisted as a partial
+ deep-merge under `hybrid.subprojects`. The config PUT now permits that
+ federation dict (the top-level child-links `subprojects[]` array stays
+ refused — hierarchy still changes only through the dedicated actions).
+- **Staged re-parent wizard**: "Change parent…" on child cards — an honest
+ multi-step flow, never a fake one-click move (physical containment is
+ mandatory). Ready-now targets relink immediately; otherwise the wizard
+ unlinks first (deliberate: the child back-link can only be cleared while
+ the folder is still at its old path), offers Undo until the user moves the
+ folder, gates the new link behind validation, and cleans up the stale
+ registry row — with a per-step checklist that reports the exact state on
+ any failure.
+- **Config editor honesty**: `subprojects` / `parent` keys — previously
+ silently hidden — render as read-only rows with a "managed via sub-project
+ actions" hint; drill Overview status colors now branch on the real status
+ vocabulary (the old `'missing'`/`'error'` branches were dead code, so real
+ errors rendered as warnings).
+
+### Added — Hub UI: Reconcile action for sub-project links
+
+- Parent project cards' kebab menu gains **"Reconcile links"** (wrench icon):
+ runs the existing `/api/projects/subprojects/reconcile` consistency check
+ as a dry-run, reports "all N links consistent" when clean, and otherwise
+ shows a confirm dialog summarizing the damage (broken back-links,
+ unregistered children, registry orphans, missing folders) before repairing
+ with `fix=true`. `prune=true` is sent only when missing-folder entries were
+ detected, so pruning is always surfaced to the user before it happens.
+ `missing_c3` children are called out as not repairable by reconcile
+ (re-designate or Cascade update instead). The endpoint was previously
+ CLI/API-only.
+
## [2.56.1] - 2026-07-23
### Fixed — `c3 --version` / `c3 upgrade` reported 2.55.0 after upgrading to 2.56.0
diff --git a/CLAUDE.md b/CLAUDE.md
index 624425f..31e5145 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,7 +50,7 @@ claude-companion - v2/
THIRD_PARTY_LICENSES.md
c3.bat
install.bat
- ... +4 more
+ ... +5 more
.agents/
.github/
FUNDING.yml
@@ -126,7 +126,7 @@ claude-companion - v2/
compressor.py
context_snapshot.py
conversation_store.py
- ... +52 more
+ ... +53 more
bench/ (1 files)
tests/
test_activity_reporter.py
@@ -144,7 +144,7 @@ claude-companion - v2/
test_circuit_breaker.py
test_claude_md_merge.py
test_cli_smoke.py
- ... +77 more
+ ... +81 more
tui/
__init__.py
backend.py
@@ -170,8 +170,8 @@ Python (Modern)
## Key Facts (use c3_memory for more)
-- Session summary (20260701): Decision: Deep 4-agent evaluation of C3 completed (tool surface, hooks, services, claims-vs-
- Session summary (20260701): Files (modified): README.md, services/subprojects.py, services/project_manager.py, services/
- [validate] U:\1. Projects\Claude Code Companion (C3)\claude-companion - v2\cli\hub_ui\components\toasts.js has syntax er
- Sub-projects (v2.44.0): three-way link — parent config subprojects[] (POSIX rel_path = source of truth), child config pa
-- Hub v2 (v2.44.0): cli/hub_ui.html + cli/hub_ui/ 22-file concat bundle served at / (hub.html frozen at /legacy until v2.4
\ No newline at end of file
+- Hub v2 (v2.44.0): cli/hub_ui.html + cli/hub_ui/ 22-file concat bundle served at / (hub.html frozen at /legacy until v2.4
+- Session summary (20260702): Files (modified): services/subprojects.py, .github/workflows/release.yml, pyproject.toml, cl
\ No newline at end of file
diff --git a/README.md b/README.md
index 456fd55..615f7a3 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,14 @@ Every C3-initialized project on your machine appears here automatically — `c3
Per-card actions cover the full lifecycle: launch the IDE, open the per-project UI, edit name / tags / notes, transfer the registration to a new path, **merge** another project's memory + conversation history + edit ledger into this one (with optional source cleanup), or remove it from the registry.
+**Sub-projects — nested repos as first-class children.** Designate any sub-folder (a service in a monorepo, a vendored tool) as a linked child project with its own `.c3`: the parent's index excludes the child's subtree, and `c3_search` / `c3_memory` fan out across children on demand (`scope='all'` or one child by name). The whole hierarchy is manageable from the hub:
+
+- **Designate** from any project card or the drill-in **Sub-projects** tab — folder picker with upfront validation, adopt-vs-initialize preview, and IDE choice. An existing top-level project that physically sits inside a parent can be re-linked via **"Make sub-project of…"**.
+- **Link health is passive** — parent cards show a red "N link issues" badge and children show their link status automatically; **Reconcile** shows exactly what's broken and repairs it on confirm.
+- **Cascade** update / reindex / health across all children (cancellable, optionally including the parent), **promote** a child back to top-level, or **de-initialize** it entirely behind a typed-name confirm.
+- **Change parent…** moves a child between parents honestly: folders must physically nest, so the wizard stages the move, validates every step, and never leaves a broken state silently.
+- Federation behavior per parent — memory roll-up, search fan-out, children per query — is editable in the project's config editor.
+
**Open in your IDE of choice** — C3 auto-detects which CLIs you have installed and gives you one-click launchers:
v2.31.0 — discover and operate on OTHER c3-installed projects; reads run free, writes need allow_write=true. Tool card →
+
+
+
+
Sub-projects
+
c3 sub add <folder>
+
v2.44.0 — link child projects living inside a parent; federated search/memory + Hub management. Section →
+
@@ -897,6 +904,86 @@
Terse Advisor — automatic nudge
+
+
+
🌳 Sub-projects
+
+
Since v2.44.0, a c3 project can link folders that physically live inside it as sub-projects. Each child gets its own full .c3 (index, memory, config), the parent's index excludes the child's subtree, and the link is recorded in three places at once: the parent config's subprojects[] array, a back-link in the child's config, and the hub registry. Links are depth-1 — a child cannot have children of its own.
+
+
+ 💡
+
+ Use sub-projects for monorepos and nested apps: each child stays independently searchable with its own memory, while the parent can fan queries out across the whole family.
+
+
+
+
CLI — c3 sub
+
+
Command
Effect
+
+
+
c3 sub add <folder> [--name --ide]
+
Link a folder inside the current project. Adopts an existing .c3, otherwise runs a full init. --no-init links without initializing; --parent <path> targets a different parent.
+
+
+
c3 sub list
+
Tree of linked children with status (the default when no sub-command is given).
+
+
+
c3 sub remove <ref> [--clear]
+
Unlink a child and promote it to a standalone top-level project (its .c3 is kept). --clear also wipes .c3, uninstalls the MCP config and instruction docs, and deregisters it.
+
+
+
c3 sub run <update|reindex|health> [--include-parent]
+
Cascade an operation across every child. --mcp also reinstalls the MCP config during update.
+
+
+
c3 sub check [--fix --prune]
+
Reconcile the three-way links. --fix repairs from the parent config; adding --prune drops entries whose folder is gone.
+
+
+
+
+
Agent surface — the scope parameter
+
c3_search and c3_memory accept a scope parameter on parent projects:
With hybrid.subprojects.memory_rollup enabled, a parent-side recall also unions in the children's facts automatically.
+
+
Hub UI management (v2.57.0)
+
Everything the CLI does — plus the operations that need multi-step safety — is available in the Hub:
+
+
Designate — kebab menu → "Designate sub-project…" on any project card, or from the drill-in Sub-projects tab. A folder picker fenced to the parent's subtree validates the choice, shows adopt-vs-initialize expectations up front, and offers an instruction-docs/IDE picker (auto | claude | vscode | cursor | codex | antigravity).
+
"Make sub-project of…" — the reverse move: a top-level card whose folder physically sits inside a registered parent can link itself under it (containment is mandatory).
+
Sub-projects drill tab — per-child rows with inline Validate and Promote; inline Reconcile with per-issue detail and planned repair actions; a Cascade launcher with operation picker, include-parent toggle, affected-children preview, live progress, and Cancel.
+
Link health, passively — parents show a red "N link issues" badge and children a status badge (ok · backlink_broken · unregistered · missing_folder · missing_c3 · orphan), computed from config + filesystem on every listing — no manual reconcile needed to spot trouble.
+
"Change parent…" — a staged wizard, because folders must physically move: ready-now targets relink immediately; otherwise unlink → move the folder yourself → validate → relink, with a per-step checklist and Undo before the move.
+
"De-initialize…" — clear-mode removal behind a typed-name confirmation: deletes .c3, uninstalls the MCP config, removes instruction docs, deregisters. Your code is untouched.
+
Federation toggles — the config editor's Sub-projects section on parent projects: memory roll-up, search fan-out for scope='all', and max children per query, persisted under hybrid.subprojects.
+
Ctrl-K scope chips — global search can target All, Top-level only, or one parent + its children; child results carry a "parent › child" breadcrumb.
+
+
+
+ ⚠️
+
+ Managed keys
+ The raw subprojects / parent config keys render read-only in the Hub config editor by design — hierarchy changes only go through the dedicated actions above (CLI or Hub), never by hand-editing the link records.
+
+
+
+
+
+
⚠️ When Native Tools Are Permitted
diff --git a/cli/hub_server.py b/cli/hub_server.py
index c83ca3b..9fd3870 100644
--- a/cli/hub_server.py
+++ b/cli/hub_server.py
@@ -333,6 +333,7 @@ def _win_find_ide(cmd: str) -> str:
"hub_ui/components/session_drawer.js",
"hub_ui/components/drill_panel.js",
"hub_ui/components/drill_views.js",
+ "hub_ui/components/drill_subprojects.js",
"hub_ui/components/drill_health.js",
"hub_ui/components/drill_tasks.js",
"hub_ui/components/drill_artifacts.js",
@@ -477,6 +478,129 @@ def _notification_count(project_path: str) -> int:
return count
+_sub_links_cache = {"fp": None, "ts": 0.0, "data": None, "computing": False}
+_sub_links_lock = threading.Lock()
+_SUB_LINKS_TTL = 15.0
+
+
+def _invalidate_subproject_links():
+ """Drop the cached link-health snapshot (call after any hierarchy mutation)."""
+ with _sub_links_lock:
+ _sub_links_cache.update({"fp": None, "ts": 0.0, "data": None})
+
+
+def _sub_federation_errors(sub):
+ """Schema check for the hybrid.subprojects federation dict (config PUT)."""
+ allowed = {"memory_rollup", "search_fanout", "max_children_per_query"}
+ unknown = sorted(set(sub) - allowed)
+ if unknown:
+ return f"unknown keys {unknown}"
+ for k in ("memory_rollup", "search_fanout"):
+ if k in sub and not isinstance(sub[k], bool):
+ return f"'{k}' must be a boolean"
+ v = sub.get("max_children_per_query")
+ if v is not None and (isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 256):
+ return "'max_children_per_query' must be an integer between 1 and 256"
+ return ""
+
+
+def _norm_realpath(s):
+ """resolve()+normcase, matching services.subprojects._norm — plain
+ normcase alone would flag symlinked/8.3/trailing-slash registry rows
+ as false orphans that Reconcile then denies."""
+ try:
+ return os.path.normcase(str(Path(s).resolve()))
+ except Exception:
+ return os.path.normcase(str(s or ""))
+
+
+def _compute_subproject_links(projects):
+ """Statuses only — deliberately NOT SubprojectManager.list(), which
+ also parses facts.json and notifications.jsonl per child; those counts
+ were computed and thrown away on every poll recompute."""
+ from services.subprojects import SubprojectManager, get_subprojects
+ parents_out, children_out = {}, {}
+ registry = None
+ for parent in projects:
+ if not (parent.get("is_parent") or parent.get("subproject_count")):
+ continue
+ try:
+ mgr = SubprojectManager(parent.get("path", ""))
+ entries = get_subprojects(mgr.parent_path)
+ if registry is None:
+ registry = mgr.pm._read_projects()
+ except Exception:
+ continue
+ issues = 0
+ config_paths = set()
+ for entry in entries:
+ try:
+ key = _norm_realpath(mgr._abs_child(entry.get("rel_path", "")))
+ status = mgr._entry_status(entry, registry)
+ except Exception:
+ continue
+ config_paths.add(key)
+ if status != "ok":
+ issues += 1
+ children_out[key] = status
+ parent_key = _norm_realpath(parent.get("path", ""))
+ for row in projects:
+ rp = row.get("parent_path")
+ if (rp and _norm_realpath(rp) == parent_key
+ and _norm_realpath(row.get("path", "")) not in config_paths):
+ issues += 1
+ children_out[_norm_realpath(row.get("path", ""))] = "orphan"
+ if issues:
+ parents_out[parent_key] = issues
+ return {"parents": parents_out, "children": children_out}
+
+
+def _annotate_subproject_links(projects):
+ """Per-parent link-health rollup + per-child link_status for the cards.
+
+ SubprojectManager.list() is config + filesystem + raw-registry reads
+ only (no child runtimes, no port probing). Registry orphans (rows
+ claiming a parent that has no matching config entry) count as issues
+ too — same rule as reconcile. The snapshot is cached _SUB_LINKS_TTL
+ seconds and computed single-flight, so overlapping UI polls never
+ stack behind a slow disk (e.g. a disconnected network drive); the
+ fingerprint ties the cache to the registry rows, and mutating
+ sub-project endpoints call _invalidate_subproject_links().
+ """
+ fp = tuple(sorted((os.path.normcase(p.get("path", "")),
+ os.path.normcase(p.get("parent_path") or ""))
+ for p in projects))
+ now = time.time()
+ do_compute = False
+ with _sub_links_lock:
+ snap = _sub_links_cache["data"]
+ fresh = (snap is not None and _sub_links_cache["fp"] == fp
+ and now - _sub_links_cache["ts"] < _SUB_LINKS_TTL)
+ if not fresh:
+ if _sub_links_cache["computing"]:
+ if _sub_links_cache["fp"] != fp:
+ snap = None # stale snapshot of a different registry — skip
+ else:
+ _sub_links_cache["computing"] = True
+ do_compute = True
+ if do_compute:
+ try:
+ snap = _compute_subproject_links(projects)
+ with _sub_links_lock:
+ _sub_links_cache.update({"fp": fp, "ts": time.time(), "data": snap})
+ finally:
+ with _sub_links_lock:
+ _sub_links_cache["computing"] = False
+ if not snap:
+ return
+ for p in projects:
+ key = _norm_realpath(p.get("path", ""))
+ if key in snap["parents"]:
+ p["subproject_issues"] = snap["parents"][key]
+ if key in snap["children"]:
+ p["link_status"] = snap["children"][key]
+
+
@app.route("/api/projects", methods=["GET"])
def api_projects_list():
try:
@@ -487,6 +611,7 @@ def api_projects_list():
p["notification_count"] = _notification_count(p.get("path", ""))
p["is_parent"] = os.path.normcase(p.get("path", "")) in parent_paths
p["open_task_count"] = open_task_count(p.get("path", ""))
+ _annotate_subproject_links(projects)
return jsonify(projects)
except Exception as e:
return jsonify({"error": str(e)}), 500
@@ -519,6 +644,8 @@ def api_projects_remove():
orphans = [p.get("path") for p in _pm().list_projects()
if p.get("parent_path") and os.path.normcase(p["parent_path"]) == resolved]
result = {"removed": _pm().remove_project(path)}
+ if result["removed"]:
+ _invalidate_subproject_links()
if result["removed"] and orphans:
result["orphaned_children"] = orphans
return jsonify(result)
@@ -1226,6 +1353,8 @@ def api_subprojects_add():
res = _run_c3(args, timeout=300)
payload = _parse_json_tail(res.get("output", ""))
ok = res.get("success") and bool((payload or {}).get("added"))
+ if ok:
+ _invalidate_subproject_links()
body = {"success": ok, "result": payload, "output": res.get("output", "")}
return jsonify(body), (201 if ok else 500)
@@ -1247,6 +1376,8 @@ def api_subprojects_remove():
res = _run_c3(args, timeout=300)
payload = _parse_json_tail(res.get("output", ""))
ok = res.get("success") and bool((payload or {}).get("removed"))
+ if ok:
+ _invalidate_subproject_links()
body = {"success": ok, "result": payload, "output": res.get("output", "")}
return jsonify(body), (200 if ok else 500)
@@ -1259,8 +1390,11 @@ def api_subprojects_reconcile():
if not parent:
return jsonify({"error": "parent is required"}), 400
try:
- return jsonify(_sub_manager(parent).reconcile(
- fix=bool(data.get("fix")), prune=bool(data.get("prune"))))
+ result = _sub_manager(parent).reconcile(
+ fix=bool(data.get("fix")), prune=bool(data.get("prune")))
+ if data.get("fix"):
+ _invalidate_subproject_links()
+ return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 404
except Exception as e:
@@ -1321,6 +1455,7 @@ def _sub_cascade_worker(op: str, targets: list):
_sub_cascade_state["running"] = False
_sub_cascade_state["done"] = True
_sub_cascade_state["current"] = None
+ _invalidate_subproject_links()
@app.route("/api/projects/subprojects/cascade", methods=["POST"])
@@ -1339,11 +1474,20 @@ def api_subprojects_cascade():
return jsonify({"error": "op must be update|reindex|health"}), 400
try:
sm = _sub_manager(parent)
- targets = sm.list()
+ listed = sm.list()
+ # Skipped children (missing folder; missing .c3 unless the op can
+ # create it) are excluded up front so total and the success
+ # denominator reflect real work — previously they inflated total
+ # and reported as failures.
+ skipped = [t for t in listed
+ if t.get("status") == "missing_folder"
+ or (t.get("status") == "missing_c3" and op != "update")]
+ targets = [t for t in listed if t not in skipped]
if data.get("include_parent"):
targets.append({"name": "(parent)", "path": sm.parent_path, "status": "ok"})
if not targets:
- return jsonify({"error": "No sub-projects designated"}), 400
+ return jsonify({"error": "No cascade-eligible sub-projects"
+ + (f" ({len(skipped)} skipped)" if skipped else "")}), 400
with _sub_cascade_lock:
_sub_cascade_state = {
"running": True, "cancelled": False, "results": [],
@@ -1352,7 +1496,8 @@ def api_subprojects_cascade():
}
t = threading.Thread(target=_sub_cascade_worker, args=(op, targets), daemon=True)
t.start()
- return jsonify({"started": True, "total": len(targets), "op": op})
+ return jsonify({"started": True, "total": len(targets), "op": op,
+ "skipped": len(skipped)})
except ValueError as e:
return jsonify({"error": str(e)}), 404
except Exception as e:
@@ -1643,6 +1788,18 @@ def api_projects_config_put():
return jsonify({"error": "values must be an object"}), 400
for refused in _CONFIG_REFUSED_KEYS:
if refused in values:
+ # 'subprojects' under the hybrid section is the federation
+ # settings DICT (memory_rollup / search_fanout / …), not the
+ # top-level child-links ARRAY — the array stays refused (and is
+ # unreachable through the section whitelist anyway). Schema-check
+ # the dict: exactly these keys, exactly these types — the generic
+ # deep-merge below would happily persist anything.
+ if (section == "hybrid" and refused == "subprojects"
+ and isinstance(values["subprojects"], dict)):
+ bad = _sub_federation_errors(values["subprojects"])
+ if bad:
+ return jsonify({"error": f"hybrid.subprojects: {bad}"}), 400
+ continue
return jsonify({"error": f"'{refused}' cannot be edited here"}), 400
try:
resolved = _resolve_project_path(path)
diff --git a/cli/hub_ui/app.js b/cli/hub_ui/app.js
index 04afc48..71b961c 100644
--- a/cli/hub_ui/app.js
+++ b/cli/hub_ui/app.js
@@ -128,7 +128,7 @@ function App() {
{drawerProject &&
setDrawerProject(null)} />}
{drill &&
- setDrill(null)} onChanged={loadProjects} onOpenModal={openModal} />}
setSearchOpen(false)} projects={projects}
onOpenProject={(p, tab) => { setSearchOpen(false); openDrill(p, tab || 'overview'); }} />
diff --git a/cli/hub_ui/components/config_editor.js b/cli/hub_ui/components/config_editor.js
index 2e96618..07bdf34 100644
--- a/cli/hub_ui/components/config_editor.js
+++ b/cli/hub_ui/components/config_editor.js
@@ -6,6 +6,15 @@
const CFG_ED_SECTIONS = ['hybrid', 'agents', 'delegate', 'proxy', 'mcp', 'meta', 'memory_llm', 'bitbucket'];
const CFG_ED_READONLY = ['bitbucket'];
const CFG_ED_REFUSED = ['version', 'project_path', 'permission_tier', 'subprojects', 'parent', 'api_key'];
+// Refused keys worth surfacing read-only when present, rather than hiding —
+// still never editable here (server refuses the write regardless).
+const CFG_ED_MANAGED = ['subprojects', 'parent'];
+const CFG_ED_MANAGED_HINT = 'Managed via sub-project actions — use the project card menu or drill-in panel.';
+// hybrid.subprojects federation settings get a friendly "Sub-projects"
+// section on parent projects. The hub API accepts dict-valued 'subprojects'
+// under section=hybrid (schema-checked server-side); the read-only fallback
+// below only engages if this flag is ever flipped off again.
+const CFG_ED_SUBPROJECTS_WRITABLE = true;
function ConfigEditor({ project }) {
const [section, setSection] = useState('hybrid');
@@ -36,11 +45,43 @@ function ConfigEditor({ project }) {
};
useEffect(() => { setCfg(null); load(); }, [project.path, section]);
+ // ── Sub-projects (hybrid.subprojects): friendly federation controls ─────
+ // Shown for parent projects on the hybrid section. Edits nest under one
+ // 'subprojects' key holding only the changed sub-keys, so the server's
+ // deep-merge preserves untouched siblings (e.g. parent_memory_visible).
+ const isParent = !!project.is_parent || (project.subproject_count || 0) > 0;
+ const showSubprojects = section === 'hybrid' && !!cfg
+ && (isParent || cfg.subprojects !== undefined);
+ const subCfg = (cfg && cfg.subprojects && typeof cfg.subprojects === 'object') ? cfg.subprojects : {};
+ const subDefs = (defs.subprojects && typeof defs.subprojects === 'object') ? defs.subprojects : {};
+ const subEdits = (edits.subprojects && typeof edits.subprojects === 'object') ? edits.subprojects : {};
+ const subVal = (k) => Object.prototype.hasOwnProperty.call(subEdits, k) ? subEdits[k]
+ : (subCfg[k] !== undefined ? subCfg[k] : subDefs[k]);
+ const subEdited = (k) => Object.prototype.hasOwnProperty.call(subEdits, k);
+ const subDot = (k) => subEdited(k)
+ ?
+ : null;
+ const setSubEdit = (k, v) => {
+ if (!CFG_ED_SUBPROJECTS_WRITABLE) return;
+ setEdits(e => {
+ const cur = Object.assign({}, (e.subprojects && typeof e.subprojects === 'object') ? e.subprojects : {});
+ cur[k] = v;
+ return Object.assign({}, e, { subprojects: cur });
+ });
+ };
+
const keys = [];
+ const managedKeys = [];
if (cfg) {
- Object.keys(defs).forEach(k => { if (CFG_ED_REFUSED.indexOf(k) < 0) keys.push(k); });
+ Object.keys(defs).forEach(k => {
+ if (CFG_ED_REFUSED.indexOf(k) < 0) keys.push(k);
+ else if (CFG_ED_MANAGED.indexOf(k) >= 0 && managedKeys.indexOf(k) < 0
+ && !(k === 'subprojects' && showSubprojects)) managedKeys.push(k);
+ });
Object.keys(cfg).forEach(k => {
- if (CFG_ED_REFUSED.indexOf(k) < 0 && keys.indexOf(k) < 0) keys.push(k);
+ if (CFG_ED_REFUSED.indexOf(k) < 0) { if (keys.indexOf(k) < 0) keys.push(k); }
+ else if (CFG_ED_MANAGED.indexOf(k) >= 0 && managedKeys.indexOf(k) < 0
+ && !(k === 'subprojects' && showSubprojects)) managedKeys.push(k);
});
}
const currentVal = (k) => Object.prototype.hasOwnProperty.call(edits, k) ? edits[k]
@@ -66,6 +107,14 @@ function ConfigEditor({ project }) {
v = Number(v);
if (!isFinite(v)) throw new Error(`'${k}' must be a number`);
}
+ if (k === 'subprojects' && v && typeof v === 'object'
+ && Object.prototype.hasOwnProperty.call(v, 'max_children_per_query')) {
+ const n = Number(v.max_children_per_query);
+ if (!isFinite(n) || n < 1 || Math.floor(n) !== n) {
+ throw new Error("'max_children_per_query' must be a positive integer");
+ }
+ v = Object.assign({}, v, { max_children_per_query: n });
+ }
values[k] = v;
});
const d = await api.put('/api/projects/config', { path: project.path, section, values });
@@ -83,6 +132,13 @@ function ConfigEditor({ project }) {
Object.keys(defs).forEach(k => {
if (CFG_ED_REFUSED.indexOf(k) < 0) next[k] = JSON.parse(JSON.stringify(defs[k] === undefined ? null : defs[k]));
});
+ if (showSubprojects && CFG_ED_SUBPROJECTS_WRITABLE) {
+ next.subprojects = {
+ memory_rollup: subDefs.memory_rollup !== false,
+ search_fanout: subDefs.search_fanout !== false,
+ max_children_per_query: subDefs.max_children_per_query !== undefined ? subDefs.max_children_per_query : 8,
+ };
+ }
setEdits(next);
setJsonText({}); setJsonBad({});
notify(`Prefilled ${section} defaults — review, then Save section`, 'warn');
@@ -91,6 +147,18 @@ function ConfigEditor({ project }) {
const dirty = Object.keys(edits).length > 0;
const invalid = Object.keys(jsonBad).some(k => jsonBad[k]);
+ const renderManagedField = (k) => {
+ const val = cfg && cfg[k] !== undefined ? cfg[k] : defs[k];
+ const text = (val !== null && typeof val === 'object')
+ ? JSON.stringify(val) : String(val === undefined ? '' : val);
+ return (
+