Skip to content

Canvas compound expand/collapse/advance-pin (D6) - #161

Merged
Travis-Gilbert merged 1 commit into
mainfrom
feat/canvas-compound-nodes-d6
Aug 3, 2026
Merged

Canvas compound expand/collapse/advance-pin (D6)#161
Travis-Gilbert merged 1 commit into
mainfrom
feat/canvas-compound-nodes-d6

Conversation

@Travis-Gilbert

@Travis-Gilbert Travis-Gilbert commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • Wires CommonPlace program canvas to Theorem compound mutations (expand_node / collapse_node / advance_pin) plus environment/interior helpers.
  • Regenerates program contracts for Compound, EnvironmentSpec, and collapsed_from.
  • Keeps Compounds out of the insert catalog; expand an atom on the canvas instead.

Depends on Theorem PR https://github.com/Travis-Gilbert/Theorem/pull/437.

Test plan

  • pnpm exec vitest run src/views/program/programClient.test.ts src/views/program/catalogNode.test.ts (10 passed)
  • Manual: open a saved program, Expand a Rule, edit interior, Advance pin, Collapse

Summary by CodeRabbit

  • New Features

    • Added support for compound nodes in the program canvas.
    • Users can expand and collapse compound nodes, open their interiors, and advance pins after saving changes.
    • Added visual stack icons for compound nodes.
    • Added responsive selection controls and improved handling for loading and errors.
  • Bug Fixes

    • Prevented compound catalog entries from being inserted directly; they must first be expanded.
  • Tests

    • Added coverage for compound-node actions, environments, interiors, and validation.

Wire programClient and ProgramView to Theorem compound mutations so CommonPlace can expand atoms, collapse single-sink interiors, and advance pins without inventing Compounds from the catalog.
Copilot AI review requested due to automatic review settings August 3, 2026 01:03
@ecc-tools

ecc-tools Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The console now supports compound program nodes. It adds typed client operations, canvas expansion, collapse, interior loading, pin advancement, compound-node icons, catalog insertion validation, and focused tests.

Changes

Compound node canvas

Layer / File(s) Summary
Compound client operations and response normalization
apps/console/src/views/program/programClient.ts, apps/console/src/views/program/programClient.test.ts
Added typed mutation, environment, and interior operations. Tests verify requests, responses, persisted interiors, and metadata normalization.
Canvas compound workflows and controls
apps/console/src/views/program/ProgramView.tsx, .harness/checklists/...
Added expansion, collapse, interior loading, pin advancement, pending-pin tracking, environment defaults, run options, responsive controls, and validation evidence.
Compound node rendering and insertion validation
apps/console/src/views/program/ProgramNodeView.tsx, apps/console/src/views/program/programNodeKind.tsx, apps/console/src/views/program/catalogNode.ts, apps/console/src/views/program/catalogNode.test.ts
Compound nodes render with StackIcon. Catalog insertion rejects compound entries and instructs callers to expand an atom.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ProgramView
  participant programClient
  participant CompoundApplyAction
  participant Canvas
  User->>ProgramView: select compound node
  ProgramView->>programClient: request compound mutation
  programClient->>CompoundApplyAction: send node and program identifiers
  CompoundApplyAction-->>programClient: return normalized mutation
  programClient-->>ProgramView: return exterior definitions
  ProgramView->>Canvas: update compound node state
Loading

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the compound canvas expand, collapse, and pin-advancement changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/canvas-compound-nodes-d6

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Wires the Console “Program canvas” UI to new Theorem compound-node capabilities by adding client actions for expand_node / collapse_node / advance_pin, updating generated program contracts (Compound + environment), and exposing basic canvas controls to expand/collapse/open interior/advance pin.

Changes:

  • Regenerates @commonplace/program-contracts to include compound nodes, environment, and collapsed_from.
  • Adds program client methods + tests for compound mutations and environment/interior queries.
  • Updates canvas UI (and node kind icons) to support compound expand/collapse/open interior/advance pin, and prevents compound insertion from the catalog.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/program-contracts/src/program.generated.ts Updates generated TS contracts for compound/environment support.
apps/console/src/views/program/ProgramView.tsx Adds compound mutation UI (expand/collapse/open interior/advance pin) and draft/environment wiring.
apps/console/src/views/program/ProgramNodeView.tsx Adds icon support for compound node kind.
apps/console/src/views/program/programNodeKind.tsx Adds badge/icon support for compound node kind in kind helpers.
apps/console/src/views/program/programClient.ts Adds compound mutation/query client calls and result parsing.
apps/console/src/views/program/programClient.test.ts Adds vitest coverage for compound client calls and environment/interior queries.
apps/console/src/views/program/catalogNode.ts Rejects compound catalog inserts (must expand on canvas).
apps/console/src/views/program/catalogNode.test.ts Tests catalog refusal behavior for compound entries.
.harness/checklists/compound-nodes-d6--plan-canvas-compound.json Adds a checklist artifact documenting D6 plan completion and evidence.
Suppressed comments (1)

apps/console/src/views/program/programClient.ts:500

  • fetchProgramInterior returns empty strings for interior_program_id / pinned_content_id when the server response is missing those fields. Since callers use these as ids (e.g. openProgram(...)), this can result in attempting to open "". Consider requiring interior_program_id and defaulting pinned_content_id to it when absent.
  return {
    exterior_node_id: typeof data.exterior_node_id === 'string' ? data.exterior_node_id : '',
    node_id: typeof data.node_id === 'string' ? data.node_id : input.nodeId,
    interior_program_id: typeof data.interior_program_id === 'string' ? data.interior_program_id : '',
    pinned_content_id: typeof data.pinned_content_id === 'string' ? data.pinned_content_id : '',
    program: data.program as ProgramDefinition,
  };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +401 to +423
function asCompoundMutationResult(data: Record<string, unknown>): CompoundMutationResult {
if (!data.program || typeof data.program !== 'object' || Array.isArray(data.program)) {
throw new Error('compound_mutation_missing_program');
}
const persisted = Array.isArray(data.persisted_interiors)
? data.persisted_interiors.flatMap((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const row = item as Record<string, unknown>;
if (typeof row.node_id !== 'string' || typeof row.content_id !== 'string') return [];
return [{ node_id: row.node_id, content_id: row.content_id }];
})
: [];
return {
node_id: typeof data.node_id === 'string' ? data.node_id : '',
content_id: typeof data.content_id === 'string' ? data.content_id : '',
exterior_program_id: typeof data.exterior_program_id === 'string' ? data.exterior_program_id : '',
program: data.program as ProgramDefinition,
event: data.event && typeof data.event === 'object' && !Array.isArray(data.event)
? data.event as Record<string, unknown>
: {},
persisted_interiors: persisted,
};
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5da628b47

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +295 to +296
/** Interior content ids waiting to become the Compound pin after an interior save. */
const [pendingPinByNode, setPendingPinByNode] = useState<Record<string, string>>({});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load advance candidates from persisted interior history

pendingPinByNode starts empty and is populated only when this mounted canvas observes an interior save, while the Advance pin button is disabled solely from this map. After a page reload, another-client edit, or navigation before the save callback completes, the newer interior remains persisted but there is no way to select it for advance_pin; derive the candidate content ID from persisted lineage/history rather than session-only React state.

AGENTS.md reference: apps/console/AGENTS.md:L180-L180

Useful? React with 👍 / 👎.

Comment on lines +734 to +735
setDefinition(next);
setProgramId(nextProgramId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel pending autosaves before compound mutations

If any canvas edit schedules the 400 ms autosave and the reader immediately expands, collapses, or advances a pin, this function applies the mutation without cancelling that timer or incrementing draftGeneration. When the older save finishes after the compound request, it persists the pre-mutation definition as the latest revision, so a successful compound action silently disappears on reload; invalidate or flush the pending save before applying the mutation result.

AGENTS.md reference: apps/console/AGENTS.md:L180-L180

Useful? React with 👍 / 👎.

Comment on lines +736 to +739
const flow = definitionToFlow(next, catalogById, layoutRef.current, nodeHandlers, widgetTweaks);
setNodes(flow.nodes);
setEdges(flow.edges);
setNotice(noticeText);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate parked runs after compound mutations

When a run is parked for human input and the reader expands, collapses, or advances a node, this update leaves runReceipt and runInvocationId intact. The Run rail therefore continues to offer Resume, but resumeRun submits the old resume token with the newly mutated definition, which cannot correctly resume the execution that produced that token; clear the prior run state whenever a compound mutation changes the program.

Useful? React with 👍 / 👎.

Comment on lines +74 to +76
case 'compound':
throw new Error(
`Catalog entry ${entry.id} is Compound; expand an atom on the canvas instead of inserting from the catalog.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude compound descriptors from the insertion palette

When the server catalog contains an entry using the newly permitted compound node kind, NodePalette presents it as a selectable operation, but every selection reaches this unconditional throw and can never insert anything. Filter compound descriptors out of both the full catalog and valid_next results, since compound nodes are created through Expand rather than catalog insertion.

Useful? React with 👍 / 👎.

): Promise<void> {
setDefinition(next);
setProgramId(nextProgramId);
const flow = definitionToFlow(next, catalogById, layoutRef.current, nodeHandlers, widgetTweaks);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rebind node handlers to the mutated definition

definitionToFlow receives the nodeHandlers object from the render that started the asynchronous mutation, so its flag handler still closes over the pre-mutation definition and nodes. After an expand or collapse succeeds, using the bypass or mute controls inside the node card invokes that stale handler and saves the old atom or Compound definition back over the mutation; build these handlers from live refs or otherwise refresh them after applying the result.

AGENTS.md reference: apps/console/AGENTS.md:L180-L180

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/console/src/views/program/ProgramNodeView.tsx (1)

48-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate the duplicate KindIcon switch.

ProgramNodeView.tsx and programNodeKind.tsx each define an identical KindIcon function mapping node kind to a Radix icon. This PR had to add the same compound case to both. Keeping one shared function removes the risk of the two switches drifting apart when a future node kind is added.

  • apps/console/src/views/program/ProgramNodeView.tsx#L48-L68: import a shared KindIcon (or icon map) instead of keeping a local copy of the switch.
  • apps/console/src/views/program/programNodeKind.tsx#L84-L105: same; extract the switch to one shared module and import it from both files.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/views/program/ProgramNodeView.tsx` around lines 48 - 68,
Consolidate the duplicate KindIcon switch into one shared module. In
apps/console/src/views/program/ProgramNodeView.tsx lines 48-68, remove the local
KindIcon implementation and import the shared component or icon map; in
apps/console/src/views/program/programNodeKind.tsx lines 84-105, do the same by
extracting or reusing that shared implementation. Preserve all existing
kind-to-icon mappings, including compound.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/console/src/views/program/ProgramView.tsx`:
- Around line 724-834: Capture draftGeneration.current before the awaited
network call in expandSelectedNode, collapseSelectedNode, and
advanceSelectedPin, then check it after the await and before
applyCompoundExterior; ignore stale responses when the generation has changed so
they cannot overwrite the currently open program.

---

Nitpick comments:
In `@apps/console/src/views/program/ProgramNodeView.tsx`:
- Around line 48-68: Consolidate the duplicate KindIcon switch into one shared
module. In apps/console/src/views/program/ProgramNodeView.tsx lines 48-68,
remove the local KindIcon implementation and import the shared component or icon
map; in apps/console/src/views/program/programNodeKind.tsx lines 84-105, do the
same by extracting or reusing that shared implementation. Preserve all existing
kind-to-icon mappings, including compound.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 222a8c63-ea80-45eb-ba7e-8a02492d9c5a

📥 Commits

Reviewing files that changed from the base of the PR and between 2e611c7 and e5da628.

⛔ Files ignored due to path filters (1)
  • packages/program-contracts/src/program.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (8)
  • .harness/checklists/compound-nodes-d6--plan-canvas-compound.json
  • apps/console/src/views/program/ProgramNodeView.tsx
  • apps/console/src/views/program/ProgramView.tsx
  • apps/console/src/views/program/catalogNode.test.ts
  • apps/console/src/views/program/catalogNode.ts
  • apps/console/src/views/program/programClient.test.ts
  • apps/console/src/views/program/programClient.ts
  • apps/console/src/views/program/programNodeKind.tsx

Comment on lines +724 to +834
function programIdentityForCompound(): string {
if (programId) return programId;
throw new Error('Save or open a program before compound mutations.');
}

async function applyCompoundExterior(
next: ProgramDefinition,
nextProgramId: string,
noticeText: string,
): Promise<void> {
setDefinition(next);
setProgramId(nextProgramId);
const flow = definitionToFlow(next, catalogById, layoutRef.current, nodeHandlers, widgetTweaks);
setNodes(flow.nodes);
setEdges(flow.edges);
setNotice(noticeText);
}

async function expandSelectedNode(): Promise<void> {
if (!selectedNodeId) return;
setBusy(true);
setError(null);
try {
const result = await expandProgramNode({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Expanded ${selectedNodeId} into a Compound.`,
);
} catch (expandError) {
setError(expandError instanceof Error ? expandError.message : String(expandError));
} finally {
setBusy(false);
}
}

async function collapseSelectedNode(): Promise<void> {
if (!selectedNodeId) return;
setBusy(true);
setError(null);
try {
const result = await collapseProgramNode({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
setPendingPinByNode((current) => {
const next = { ...current };
delete next[selectedNodeId];
return next;
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Collapsed ${selectedNodeId} to a Rule.`,
);
} catch (collapseError) {
setError(collapseError instanceof Error ? collapseError.message : String(collapseError));
} finally {
setBusy(false);
}
}

async function openSelectedInterior(): Promise<void> {
if (!selectedNodeId) return;
setError(null);
try {
const interior = await fetchProgramInterior({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
await openProgram(interior.pinned_content_id || interior.interior_program_id);
setNotice(`Opened interior for ${selectedNodeId}.`);
} catch (interiorError) {
setError(interiorError instanceof Error ? interiorError.message : String(interiorError));
}
}

async function advanceSelectedPin(): Promise<void> {
if (!selectedNodeId) return;
const toContentId = pendingPinByNode[selectedNodeId];
if (!toContentId) {
setNotice(`No newer interior content for ${selectedNodeId}; edit and save the interior first.`);
return;
}
setBusy(true);
setError(null);
try {
const result = await advanceProgramPin({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
toContentId,
});
setPendingPinByNode((current) => {
const next = { ...current };
delete next[selectedNodeId];
return next;
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Advanced pin on ${selectedNodeId}.`,
);
} catch (advanceError) {
setError(advanceError instanceof Error ? advanceError.message : String(advanceError));
} finally {
setBusy(false);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Add the draftGeneration guard to the new compound mutation handlers.

expandSelectedNode, collapseSelectedNode, and advanceSelectedPin each await a network call and then unconditionally call applyCompoundExterior, which writes definition, nodes, edges, and programId directly. Every other async operation in this file that writes program state (openProgram, onConnect) captures draftGeneration.current before the await and checks it after, so a response belonging to a program the reader has since left cannot overwrite the one they are now in. These three functions skip that check.

If a reader starts an expand/collapse/advance-pin call and switches to a different program (or opens another program) before the response returns, the stale compound-mutation result will silently overwrite the currently open program's definition and canvas.

🐛 Proposed fix: capture and check draftGeneration in each handler
   async function expandSelectedNode(): Promise<void> {
     if (!selectedNodeId) return;
     setBusy(true);
     setError(null);
+    const generation = draftGeneration.current;
     try {
       const result = await expandProgramNode({
         programId: programIdentityForCompound(),
         nodeId: selectedNodeId,
       });
+      if (generation !== draftGeneration.current) return;
       await applyCompoundExterior(
         result.program,
         result.node_id || programIdentityForCompound(),
         `Expanded ${selectedNodeId} into a Compound.`,
       );
     } catch (expandError) {
       setError(expandError instanceof Error ? expandError.message : String(expandError));
     } finally {
       setBusy(false);
     }
   }

   async function collapseSelectedNode(): Promise<void> {
     if (!selectedNodeId) return;
     setBusy(true);
     setError(null);
+    const generation = draftGeneration.current;
     try {
       const result = await collapseProgramNode({
         programId: programIdentityForCompound(),
         nodeId: selectedNodeId,
       });
+      if (generation !== draftGeneration.current) return;
       setPendingPinByNode((current) => {
         const next = { ...current };
         delete next[selectedNodeId];
         return next;
       });
       await applyCompoundExterior(
         result.program,
         result.node_id || programIdentityForCompound(),
         `Collapsed ${selectedNodeId} to a Rule.`,
       );
     } catch (collapseError) {
       setError(collapseError instanceof Error ? collapseError.message : String(collapseError));
     } finally {
       setBusy(false);
     }
   }

   async function advanceSelectedPin(): Promise<void> {
     if (!selectedNodeId) return;
     const toContentId = pendingPinByNode[selectedNodeId];
     if (!toContentId) {
       setNotice(`No newer interior content for ${selectedNodeId}; edit and save the interior first.`);
       return;
     }
     setBusy(true);
     setError(null);
+    const generation = draftGeneration.current;
     try {
       const result = await advanceProgramPin({
         programId: programIdentityForCompound(),
         nodeId: selectedNodeId,
         toContentId,
       });
+      if (generation !== draftGeneration.current) return;
       setPendingPinByNode((current) => {
         const next = { ...current };
         delete next[selectedNodeId];
         return next;
       });
       await applyCompoundExterior(
         result.program,
         result.node_id || programIdentityForCompound(),
         `Advanced pin on ${selectedNodeId}.`,
       );
     } catch (advanceError) {
       setError(advanceError instanceof Error ? advanceError.message : String(advanceError));
     } finally {
       setBusy(false);
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function programIdentityForCompound(): string {
if (programId) return programId;
throw new Error('Save or open a program before compound mutations.');
}
async function applyCompoundExterior(
next: ProgramDefinition,
nextProgramId: string,
noticeText: string,
): Promise<void> {
setDefinition(next);
setProgramId(nextProgramId);
const flow = definitionToFlow(next, catalogById, layoutRef.current, nodeHandlers, widgetTweaks);
setNodes(flow.nodes);
setEdges(flow.edges);
setNotice(noticeText);
}
async function expandSelectedNode(): Promise<void> {
if (!selectedNodeId) return;
setBusy(true);
setError(null);
try {
const result = await expandProgramNode({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Expanded ${selectedNodeId} into a Compound.`,
);
} catch (expandError) {
setError(expandError instanceof Error ? expandError.message : String(expandError));
} finally {
setBusy(false);
}
}
async function collapseSelectedNode(): Promise<void> {
if (!selectedNodeId) return;
setBusy(true);
setError(null);
try {
const result = await collapseProgramNode({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
setPendingPinByNode((current) => {
const next = { ...current };
delete next[selectedNodeId];
return next;
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Collapsed ${selectedNodeId} to a Rule.`,
);
} catch (collapseError) {
setError(collapseError instanceof Error ? collapseError.message : String(collapseError));
} finally {
setBusy(false);
}
}
async function openSelectedInterior(): Promise<void> {
if (!selectedNodeId) return;
setError(null);
try {
const interior = await fetchProgramInterior({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
await openProgram(interior.pinned_content_id || interior.interior_program_id);
setNotice(`Opened interior for ${selectedNodeId}.`);
} catch (interiorError) {
setError(interiorError instanceof Error ? interiorError.message : String(interiorError));
}
}
async function advanceSelectedPin(): Promise<void> {
if (!selectedNodeId) return;
const toContentId = pendingPinByNode[selectedNodeId];
if (!toContentId) {
setNotice(`No newer interior content for ${selectedNodeId}; edit and save the interior first.`);
return;
}
setBusy(true);
setError(null);
try {
const result = await advanceProgramPin({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
toContentId,
});
setPendingPinByNode((current) => {
const next = { ...current };
delete next[selectedNodeId];
return next;
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Advanced pin on ${selectedNodeId}.`,
);
} catch (advanceError) {
setError(advanceError instanceof Error ? advanceError.message : String(advanceError));
} finally {
setBusy(false);
}
}
function programIdentityForCompound(): string {
if (programId) return programId;
throw new Error('Save or open a program before compound mutations.');
}
async function applyCompoundExterior(
next: ProgramDefinition,
nextProgramId: string,
noticeText: string,
): Promise<void> {
setDefinition(next);
setProgramId(nextProgramId);
const flow = definitionToFlow(next, catalogById, layoutRef.current, nodeHandlers, widgetTweaks);
setNodes(flow.nodes);
setEdges(flow.edges);
setNotice(noticeText);
}
async function expandSelectedNode(): Promise<void> {
if (!selectedNodeId) return;
setBusy(true);
setError(null);
const generation = draftGeneration.current;
try {
const result = await expandProgramNode({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
if (generation !== draftGeneration.current) return;
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Expanded ${selectedNodeId} into a Compound.`,
);
} catch (expandError) {
setError(expandError instanceof Error ? expandError.message : String(expandError));
} finally {
setBusy(false);
}
}
async function collapseSelectedNode(): Promise<void> {
if (!selectedNodeId) return;
setBusy(true);
setError(null);
const generation = draftGeneration.current;
try {
const result = await collapseProgramNode({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
if (generation !== draftGeneration.current) return;
setPendingPinByNode((current) => {
const next = { ...current };
delete next[selectedNodeId];
return next;
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Collapsed ${selectedNodeId} to a Rule.`,
);
} catch (collapseError) {
setError(collapseError instanceof Error ? collapseError.message : String(collapseError));
} finally {
setBusy(false);
}
}
async function openSelectedInterior(): Promise<void> {
if (!selectedNodeId) return;
setError(null);
try {
const interior = await fetchProgramInterior({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
});
await openProgram(interior.pinned_content_id || interior.interior_program_id);
setNotice(`Opened interior for ${selectedNodeId}.`);
} catch (interiorError) {
setError(interiorError instanceof Error ? interiorError.message : String(interiorError));
}
}
async function advanceSelectedPin(): Promise<void> {
if (!selectedNodeId) return;
const toContentId = pendingPinByNode[selectedNodeId];
if (!toContentId) {
setNotice(`No newer interior content for ${selectedNodeId}; edit and save the interior first.`);
return;
}
setBusy(true);
setError(null);
const generation = draftGeneration.current;
try {
const result = await advanceProgramPin({
programId: programIdentityForCompound(),
nodeId: selectedNodeId,
toContentId,
});
if (generation !== draftGeneration.current) return;
setPendingPinByNode((current) => {
const next = { ...current };
delete next[selectedNodeId];
return next;
});
await applyCompoundExterior(
result.program,
result.node_id || programIdentityForCompound(),
`Advanced pin on ${selectedNodeId}.`,
);
} catch (advanceError) {
setError(advanceError instanceof Error ? advanceError.message : String(advanceError));
} finally {
setBusy(false);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/views/program/ProgramView.tsx` around lines 724 - 834,
Capture draftGeneration.current before the awaited network call in
expandSelectedNode, collapseSelectedNode, and advanceSelectedPin, then check it
after the await and before applyCompoundExterior; ignore stale responses when
the generation has changed so they cannot overwrite the currently open program.

@Travis-Gilbert
Travis-Gilbert merged commit ea83bb3 into main Aug 3, 2026
13 of 17 checks passed
@Travis-Gilbert
Travis-Gilbert deleted the feat/canvas-compound-nodes-d6 branch August 3, 2026 02:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants