Skip to content

Commit d37bbac

Browse files
author
DavidQ
committed
docs: add BUILD_PR tool host multi-switch bundle
1 parent 11f2a52 commit d37bbac

File tree

9 files changed

+182
-20
lines changed

9 files changed

+182
-20
lines changed

docs/dev/CODEX_COMMANDS.md

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,16 @@ MODEL: GPT-5.4
22
REASONING: high
33

44
COMMAND:
5-
Create BUILD_PR_TOOL_HOST_FOUNDATION as the next follow-up to tool boot contract normalization.
5+
Create BUILD_PR_TOOL_HOST_MULTI_SWITCH
66

77
Scope:
8-
- add a minimal Tool Host foundation
9-
- dynamic load path for normalized tools
10-
- mount/unmount lifecycle handling
11-
- lightweight registry/manifest
12-
- preserve standalone tool pages
13-
- no theme restyling
14-
- no editor-state refactors
15-
- no render-pipeline changes
16-
- do not touch templates/ cleanup in this PR
8+
- add multi-tool switching to host
9+
- enforce lifecycle correctness
10+
- minimal UI
1711

1812
Validation:
1913
- npm run test:launch-smoke -- --tools
20-
- verify host shell loads selected tool
21-
- verify standalone tool pages still launch
22-
- report exact files changed
14+
- verify switching works
2315

2416
Output:
25-
<project folder>/tmp/BUILD_PR_TOOL_HOST_FOUNDATION_delta.zip
17+
<project>/tmp/BUILD_PR_TOOL_HOST_MULTI_SWITCH_delta.zip

docs/dev/COMMIT_COMMENT.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
docs: add BUILD_PR bundle for minimal Tool Host foundation
1+
docs: add BUILD_PR tool host multi-switch bundle
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# BUILD_PR_TOOL_HOST_MULTI_SWITCH Report
2+
3+
## Scope Outcome
4+
- Added host multi-tool switching controls with minimal UI (`Previous Tool`, `Next Tool`, and switch position readout).
5+
- Enforced tighter lifecycle handling in host runtime:
6+
- mount sequence guard to prevent stale load/error events from superseded mounts
7+
- destroy contract invocation on unmount when available via tool boot contract registry
8+
- explicit mount/unmount status for switch, reload, manual, and unload paths
9+
- Preserved standalone tool pages (iframe host only; no tool page rewrites).
10+
11+
## Validation
12+
- `npm run test:launch-smoke -- --tools`
13+
- PASS (`9/9` tools)
14+
- Multi-switch host verification (CDP):
15+
- Loaded host with `?tool=asset-browser`
16+
- Switched across:
17+
- `asset-browser`
18+
- `palette-browser`
19+
- `tile-map-editor`
20+
- `vector-map-editor`
21+
- Verified one mounted frame at each step and matching selected tool id
22+
- Verified explicit unmount clears mounted frame
23+
- Console/runtime errors: none
24+
25+
## Files Changed
26+
- `tools/shared/toolHostRuntime.js`
27+
- `tools/Tool Host/index.html`
28+
- `tools/Tool Host/main.js`
29+
- `docs/dev/reports/launch_smoke_report.md`
30+
- `docs/dev/reports/BUILD_PR_TOOL_HOST_MULTI_SWITCH_report.md`

docs/dev/reports/launch_smoke_report.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Launch Smoke Report
22

3-
Generated: 2026-04-11T23:41:41.835Z
3+
Generated: 2026-04-11T23:48:31.310Z
44

55
Filters: games=false, samples=false, tools=true, sampleRange=all
66

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
MULTI TOOL SWITCH TARGETS
2+
3+
- tool selector
4+
- mount/unmount enforcement
5+
- lifecycle validation
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# BUILD_PR_TOOL_HOST_MULTI_SWITCH
2+
3+
## Purpose
4+
Extend Tool Host foundation to support switching between multiple tools dynamically within the same session.
5+
6+
## Goals
7+
- switch tools without page reload
8+
- clean mount/unmount lifecycle
9+
- preserve state isolation between tools
10+
11+
## Scope
12+
- host switch controller
13+
- tool selection UI (minimal)
14+
- lifecycle enforcement
15+
16+
## Out of Scope
17+
- styling/theme changes
18+
- editor state persistence between tools
19+
- deep UI redesign
20+
21+
## Strategy
22+
- reuse host foundation
23+
- add tool selector
24+
- ensure destroy() always called before next init()
25+
26+
## Validation
27+
- npm run test:launch-smoke -- --tools
28+
- switch between at least 3 tools without errors

tools/Tool Host/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ <h2>Tool Host Foundation</h2>
2121
</div>
2222
<div class="meta">
2323
<button type="button" data-tool-host-mount>Load Selected Tool</button>
24+
<button type="button" data-tool-host-prev>Previous Tool</button>
25+
<button type="button" data-tool-host-next>Next Tool</button>
2426
<button type="button" data-tool-host-unmount>Unmount Tool</button>
2527
<a data-tool-host-standalone href="#" target="_blank" rel="noopener noreferrer">Open Standalone</a>
2628
</div>
29+
<p data-tool-host-switch-meta>Switching disabled until tools are loaded.</p>
2730
<p data-tool-host-status>Tool host idle.</p>
2831
</section>
2932

tools/Tool Host/main.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import { createToolHostRuntime } from "../shared/toolHostRuntime.js";
44
const refs = {
55
toolSelect: document.querySelector("[data-tool-host-select]"),
66
mountButton: document.querySelector("[data-tool-host-mount]"),
7+
prevButton: document.querySelector("[data-tool-host-prev]"),
8+
nextButton: document.querySelector("[data-tool-host-next]"),
79
unmountButton: document.querySelector("[data-tool-host-unmount]"),
810
standaloneLink: document.querySelector("[data-tool-host-standalone]"),
11+
switchMetaText: document.querySelector("[data-tool-host-switch-meta]"),
912
statusText: document.querySelector("[data-tool-host-status]"),
1013
currentLabel: document.querySelector("[data-tool-host-current-label]"),
1114
mountContainer: document.querySelector("[data-tool-host-mount-container]")
1215
};
1316

1417
const manifest = createToolHostManifest();
18+
const toolIds = manifest.tools.map((tool) => tool.id);
1519

1620
function readSelectedToolId() {
1721
return refs.toolSelect instanceof HTMLSelectElement ? refs.toolSelect.value : "";
@@ -29,6 +33,39 @@ function setCurrentLabel(text) {
2933
}
3034
}
3135

36+
function writeSwitchMeta(text) {
37+
if (refs.switchMetaText instanceof HTMLElement) {
38+
refs.switchMetaText.textContent = text;
39+
}
40+
}
41+
42+
function getSelectedToolIndex() {
43+
const selectedToolId = readSelectedToolId();
44+
return toolIds.findIndex((toolId) => toolId === selectedToolId);
45+
}
46+
47+
function updateSwitchMeta() {
48+
if (toolIds.length === 0) {
49+
writeSwitchMeta("No active tools are available in host manifest.");
50+
return;
51+
}
52+
const selectedIndex = getSelectedToolIndex();
53+
const oneBased = selectedIndex >= 0 ? selectedIndex + 1 : 1;
54+
writeSwitchMeta(`Switch target ${oneBased}/${toolIds.length}.`);
55+
}
56+
57+
function selectToolByOffset(offset) {
58+
if (!(refs.toolSelect instanceof HTMLSelectElement) || toolIds.length === 0) {
59+
return false;
60+
}
61+
62+
const currentIndex = Math.max(0, getSelectedToolIndex());
63+
const nextIndex = (currentIndex + offset + toolIds.length) % toolIds.length;
64+
refs.toolSelect.value = toolIds[nextIndex];
65+
updateSwitchMeta();
66+
return true;
67+
}
68+
3269
function updateStandaloneHref(toolId) {
3370
if (!(refs.standaloneLink instanceof HTMLAnchorElement)) {
3471
return;
@@ -66,6 +103,7 @@ function populateToolSelect(initialToolId) {
66103
.map((tool) => `<option value="${tool.id}">${tool.displayName}</option>`)
67104
.join("");
68105
refs.toolSelect.value = getToolHostEntryById(manifest, initialToolId) ? initialToolId : (manifest.tools[0]?.id || "");
106+
updateSwitchMeta();
69107
}
70108

71109
const runtime = createToolHostRuntime({
@@ -88,6 +126,7 @@ function mountSelectedTool(source = "manual") {
88126
writeStatus("Select a tool to mount.");
89127
return;
90128
}
129+
updateSwitchMeta();
91130
updateStandaloneHref(toolId);
92131
writeQueryToolId(toolId, source === "init");
93132
runtime.mountTool(toolId, {
@@ -103,6 +142,24 @@ function bindEvents() {
103142
});
104143
}
105144

145+
if (refs.prevButton instanceof HTMLButtonElement) {
146+
refs.prevButton.addEventListener("click", () => {
147+
if (!selectToolByOffset(-1)) {
148+
return;
149+
}
150+
mountSelectedTool("prev");
151+
});
152+
}
153+
154+
if (refs.nextButton instanceof HTMLButtonElement) {
155+
refs.nextButton.addEventListener("click", () => {
156+
if (!selectToolByOffset(1)) {
157+
return;
158+
}
159+
mountSelectedTool("next");
160+
});
161+
}
162+
106163
if (refs.unmountButton instanceof HTMLButtonElement) {
107164
refs.unmountButton.addEventListener("click", () => {
108165
runtime.unmountCurrentTool("manual");
@@ -111,6 +168,7 @@ function bindEvents() {
111168

112169
if (refs.toolSelect instanceof HTMLSelectElement) {
113170
refs.toolSelect.addEventListener("change", () => {
171+
updateSwitchMeta();
114172
updateStandaloneHref(readSelectedToolId());
115173
mountSelectedTool("select");
116174
});
@@ -121,6 +179,7 @@ function bindEvents() {
121179
if (refs.toolSelect instanceof HTMLSelectElement) {
122180
refs.toolSelect.value = toolId;
123181
}
182+
updateSwitchMeta();
124183
updateStandaloneHref(toolId);
125184
mountSelectedTool("popstate");
126185
});

tools/shared/toolHostRuntime.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ function createHostFrame(toolEntry, sourceUrl) {
3434
return frame;
3535
}
3636

37+
function readToolDestroyContract(frameWindow, toolId) {
38+
if (!frameWindow || typeof frameWindow !== "object") {
39+
return null;
40+
}
41+
const registry = frameWindow.__TOOLS_BOOT_CONTRACT_REGISTRY__;
42+
if (!registry || typeof registry !== "object") {
43+
return null;
44+
}
45+
const contract = registry[toolId];
46+
if (!contract || typeof contract.destroy !== "function") {
47+
return null;
48+
}
49+
return contract.destroy.bind(contract);
50+
}
51+
3752
export function createToolHostRuntime(options = {}) {
3853
const manifest = options.manifest;
3954
const mountContainer = options.mountContainer instanceof HTMLElement ? options.mountContainer : null;
@@ -42,6 +57,7 @@ export function createToolHostRuntime(options = {}) {
4257
const onUnmounted = typeof options.onUnmounted === "function" ? options.onUnmounted : (() => {});
4358

4459
let currentMount = null;
60+
let mountSequence = 0;
4561

4662
function getCurrentMount() {
4763
return currentMount ? { ...currentMount } : null;
@@ -54,13 +70,30 @@ export function createToolHostRuntime(options = {}) {
5470
}
5571

5672
const previous = currentMount;
73+
currentMount = null;
74+
75+
let destroyStatus = "not-available";
76+
try {
77+
const frameWindow = previous.frame?.contentWindow ?? null;
78+
const destroyFn = readToolDestroyContract(frameWindow, previous.tool.id);
79+
if (destroyFn) {
80+
const destroyResult = destroyFn({
81+
reason,
82+
hosted: true,
83+
source: "tool-host-runtime"
84+
});
85+
destroyStatus = destroyResult === false ? "failed" : "ok";
86+
}
87+
} catch {
88+
destroyStatus = "failed";
89+
}
90+
5791
if (previous.frame && previous.frame.parentElement === mountContainer) {
5892
previous.frame.removeAttribute("src");
5993
mountContainer.removeChild(previous.frame);
6094
}
61-
currentMount = null;
62-
onStatus(`Unmounted ${previous.tool.displayName} (${reason}).`);
63-
onUnmounted(previous.tool, reason);
95+
onStatus(`Unmounted ${previous.tool.displayName} (${reason}, destroy=${destroyStatus}).`);
96+
onUnmounted(previous.tool, reason, destroyStatus);
6497
return true;
6598
}
6699

@@ -83,12 +116,22 @@ export function createToolHostRuntime(options = {}) {
83116
unmountCurrentTool("reload");
84117
}
85118

119+
mountSequence += 1;
120+
const sequenceId = mountSequence;
86121
const sourceUrl = buildHostLaunchUrl(toolEntry, config);
87122
const frame = createHostFrame(toolEntry, sourceUrl);
88123
frame.addEventListener("load", () => {
124+
if (!currentMount || currentMount.mountSequence !== sequenceId) {
125+
return;
126+
}
127+
currentMount.loadedAt = new Date().toISOString();
89128
onStatus(`Mounted ${toolEntry.displayName}.`);
90129
}, { once: true });
91130
frame.addEventListener("error", () => {
131+
if (!currentMount || currentMount.mountSequence !== sequenceId) {
132+
return;
133+
}
134+
currentMount.failedAt = new Date().toISOString();
92135
onStatus(`Failed to load ${toolEntry.displayName}.`);
93136
}, { once: true });
94137

@@ -97,9 +140,11 @@ export function createToolHostRuntime(options = {}) {
97140
tool: toolEntry,
98141
frame,
99142
sourceUrl,
100-
mountedAt: new Date().toISOString()
143+
mountedAt: new Date().toISOString(),
144+
mountSequence: sequenceId
101145
};
102146

147+
onStatus(`Mounting ${toolEntry.displayName}...`);
103148
onMounted(toolEntry, currentMount);
104149
return getCurrentMount();
105150
}

0 commit comments

Comments
 (0)