refactor(beehive): put agent actions inside the detail pane

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-16 14:58:01 -04:00
parent 7071a16e1d
commit d216bbed7e
8 changed files with 144 additions and 35 deletions

View File

@@ -0,0 +1,74 @@
# Revised manager UX — partial implementation checkpoint
Delegation 5b89d3b2; base 9aef40a3f626200ef1a702c64e315cad5339f2d3.
**Not a delivery candidate. Do not install this checkpoint.**
Implemented:
- Removed the separate Controls frame altogether. The right-hand Details border
now encloses the report scroll and bounded, borderless action viewport. Tab/a,
focused `[Actions]`, keyboard selection and pointer activation remain real.
- Production Agents row generation groups directory rows under Registered agents
and Other agents using saved registration custody, with a Register agent row at
the top of Registered agents. Other-agent details offer Register agent.
- Both profile preview and registration reject a key differing from the selected
Other agent even without a Start continuation. Generic registration still permits
deliberate selection of a new identity.
- Removed the selectable Quit action; retained quit keys. Named Refresh harnesses.
No settings schema, retained configuration, credential custody, transport, provider
or native implementation changed. Existing runtime workflows are still present.
## Evidence
Direct Node 24.15.0 package typecheck passes. Direct Bun 1.4.2 renderer: 8/8.
Focused Node registration/navigation: 15/15. Renderer includes 100x30 long report,
keyboard and pointer actions, unchanged-refresh report scroll AND selected action
focus retention, overflow, narrow layout and hidden-input/quit ownership. This is
renderer evidence, not the requested complete actual-launcher walkthrough.
Logs: `/Users/loganj/.buzz/artifacts/beehive-revised-5b89d3b2/`.
The first renderer run exposed old-coordinate expectations after removing borders;
the final action viewport retains the old usable row count (without a third frame)
and those existing coordinate assertions pass. One initial typecheck fixture lacked
required Settings fields; corrected the fixture rather than weakening its type.
No full-package run attempted; inherited timed-out/non-green baseline is unchanged.
Reproduce focused controller tests from repository root (fresh isolated fixture;
no production keychain/provider/service/relay operations):
```sh
NODE=/path/to/node-24.15.0/bin/node
ROOT=$PWD
FIXTURE=$(mktemp -d /tmp/beehive-pairing-cli-revised-XXXXXX)
BEEHIVE_TEST_CREDENTIAL_FILE="$FIXTURE/credentials.json" \
NODE_OPTIONS="--import=$ROOT/beehive/test/manager-installed-loader.ts" \
BEEHIVE_TEST_REGISTRATION_WALKTHROUGH=1 \
"$NODE" --test beehive/test/registration-start.test.ts beehive/test/manager-navigation.test.ts
"$NODE" beehive/node_modules/typescript/bin/tsc --noEmit -p beehive
(cd beehive && /path/to/bun-1.4.2 test ./test/opentui-screen.bun.ts)
```
## Required next work before publishing/installing
1. Evolve selected-next configuration API to accept harness/provider/model/effort/
environment directly. Replace runtimeForm/name/chooser paths, registration's
saved-runtime prerequisite, and Move's runtime selection. Preserve immutable
saved records and active-run snapshots; do not implement UI-only fake runtimes.
2. Complete grouped Agents behavior for saved registrations absent from current
directory results. Group header rows currently use ordinary list navigation.
3. Contextual sign-in chooser at actual authority boundaries; signed-out sign-in
choices directly in Agents list. Verify Buzz first use without preconfigured
owner, preserving its single-selection flow.
4. Remember/discover harness state on reopen, and project env Databricks availability
using existing `databricksHost` URL validation and secure provider references.
Current addDatabricks only saves a workspace/reference (no immediate login);
do not accidentally add credential reads to passive render.
5. Extract/test actual production menu generation (existing renderer overflow test
is explicitly only a generic stress fixture), then one complete isolated 100x30
actual-launcher walkthrough using adapted existing loaders. Do not claim the
preconfigured-owner registration fixture proves Buzz first use.
6. Self-review full flow, ordinary signed-off commit/publish and exact remote-head
verification; then request separate laptop-first guarded installation. No native
rebuild needed unless subsequent code changes native sources.
This run does not push or install. Laptop/current remains the prior 9aef40a build.

View File

@@ -316,12 +316,14 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
this.profilePreview = undefined;
const identity = readHostIdentityPublic(this.hostDirectory);
const key = publicKey(agentNsec(v.secret ?? ''));
if (v.agent && v.agent !== key) throw plain('The key does not match the selected agent.');
if ((this.continuation || v.continuation) && (this.continuation?.token !== v.continuation || this.continuation?.agent !== key)) throw plain('The key does not match the selected agent. Start cancelled.');
const profile = await this.profileRead(identity.pairing.relay,key,abort.signal); check();
this.profilePreview = { publicKey: key, key: { service: 'beehive', role: 'agent', publicKey: key }, ...profile };
this.status = profile.profileState === 'found' ? 'Signed public profile found.' : profile.profileState === 'none' ? 'No profile found. You can register with the public key.' : 'Profile lookup unavailable. You can register without a profile.';
} else if (request.action === 'register-agent') {
const key = publicKey(agentNsec(v.secret ?? ''));
if (v.agent && v.agent !== key) throw plain('The key does not match the selected agent.');
const pending = this.continuation;
if ((pending || v.continuation) && (!pending || pending.token !== v.continuation || pending.agent !== key)) throw plain('Start selection changed. No operation was sent.');
if (this.profilePreview?.publicKey !== key) throw plain('Confirm the public key first.');

View File

@@ -7,7 +7,16 @@ export const managerSections = ['Host', 'Agents', 'Harnesses', 'Providers'] as c
export function sectionRows(snapshot: ManagerSnapshot, section: number): ManagerItem[] {
const settings = snapshot.settings;
if (section === 0) return snapshot.local.map(row => row.id !== 'host' ? row : ({ ...row, detail: `Host name: ${row.label}\nRelay URL: ${snapshot.hostRelay ?? snapshot.routing?.relay ?? '—'}\nState: ${snapshot.service?.state ?? 'unknown'}\nSaved revision: ${settings?.revision ?? 0}\nLoaded revision: ${snapshot.service?.revision ?? '—'}` }));
if (section === 1) return snapshot.agents;
if (section === 1) {
const registered = new Set(settings?.agents.map(agent => agent.publicKey) ?? []);
return [
{ id: 'registered-agents', label: 'Registered agents', detail: 'Registered agents' },
{ id: 'register-agent', label: ' Register agent', detail: 'Register agent' },
...snapshot.agents.filter(agent => registered.has(agent.id)),
{ id: 'other-agents', label: 'Other agents', detail: 'Other agents' },
...snapshot.agents.filter(agent => !registered.has(agent.id)),
];
}
if (section === 2) return [
...(snapshot.harnesses ?? []).map(h => ({ id: `harness:${h.id}`, label: `${h.label} · ${h.state}`, detail: `Harness: ${h.label}\nState: ${h.state}\nExecutable: ${h.executable ?? '—'}\nCLI: ${h.cli ?? '—'}` })),
...(settings?.runtimes ?? []).map(r => ({ id: `runtime:${r.id}`, label: r.name, detail: `Runtime: ${r.name}\nHarness: ${r.harness}\nProvider: ${settings?.providers.find(p => p.id === r.providerId)?.name ?? '—'}\nModel: ${r.model}\nEffort: ${r.effort ?? 'Inherit'}\nEnvironment: ${Object.keys(r.environment ?? {}).join(', ') || 'None'}` })),

View File

@@ -60,11 +60,11 @@ async function registerForm(continuation?: string, agent?: string) {
const runtime = runtimes[labels.indexOf(choice)]; if (!runtime) return;
const answer = await screen.input(`Register agent${agent ? `\n${agent}` : ''}\nMatching nsec. Hidden. Saved in Beehive’s OS credential store.`, '', true);
if (answer === undefined) return; secret = answer;
const result = await request('profile-preview',{secret,...(continuation ? {continuation} : {})});
const result = await request('profile-preview',{secret,...(agent ? {agent} : {}),...(continuation ? {continuation} : {})});
if (result.state !== 'completed') { settled = true; return; }
const preview = snapshot.profilePreview; if (!preview) return;
if (!await screen.confirm(`Register agent\n${preview.profile?.name ?? 'Name unavailable'}\n${preview.publicKey}\n${preview.profileState === 'unavailable' ? 'Profile unavailable; public key verified locally.' : preview.profileState === 'none' ? 'No relay profile found.' : ''}\nRuntime: ${runtime.name}\nHarness: ${runtime.harness} · Model: ${runtime.model}\nProvider: ${snapshot.settings?.providers.find(p => p.id === runtime.providerId)?.name ?? 'Unknown'}\nEffort: ${runtime.effort ?? 'Inherit'} · Environment: ${Object.keys(runtime.environment ?? {}).join(', ') || 'None'}\n${continuation ? 'Register and continue Start on the selected host?' : 'Save registration?'}`)) return;
await request('register-agent',{secret,runtime:runtime.id,...(continuation ? {continuation} : {})}); settled = true;
await request('register-agent',{secret,runtime:runtime.id,...(agent ? {agent} : {}),...(continuation ? {continuation} : {})}); settled = true;
} finally { secret = ''; if (continuation && !settled) await request('retire-continuation'); }
}
function eligibility(action: string) {
@@ -83,7 +83,7 @@ function render() {
const configured = snapshot.local.some(r => r.id === 'host');
const registerAction: ManagerAction = { label: 'Register agent', run: async () => {
if (!configured) { await routing('configure'); return; }
await registerForm();
await registerForm(undefined, snapshot.agents.some(row => row.id === selected) ? selected : undefined);
} };
let actions: ManagerAction[] = scope !== 1 ? [
{ label: 'Add provider', run: async () => {
@@ -156,10 +156,15 @@ The source stops before destination launch. Workspace, session and credentials s
{ label: 'Import matching owner key and sign in', run: () => routing('signin', true) },
];
if (scope === 0) actions = actions.filter(a => a.label === (snapshot.service?.state === 'running' ? 'Stop' : 'Start'));
if (scope === 1) actions.unshift(registerAction);
if (scope === 1) {
const isAgent = snapshot.agents.some(row => row.id === selected);
const registered = snapshot.settings?.agents.some(agent => agent.publicKey === selected);
if (snapshot.owner && !isAgent) actions = [registerAction, ...actions.filter(action => ['Refresh agents', 'Sign out'].includes(action.label))];
else if (snapshot.owner && !registered) actions.unshift(registerAction);
}
if (scope === 2) actions = [
...actions.filter(a => a.label === 'Add runtime'),
{ label: 'Refresh', run: () => request('runtime-form') },
{ label: 'Refresh harnesses', run: () => request('runtime-form') },
];
if (scope === 3) actions = [
...actions.filter(a => a.label === 'Add provider'),
@@ -169,7 +174,6 @@ The source stops before destination launch. Workspace, session and credentials s
if (snapshot.models) await screen.choose('Models', snapshot.models.map(id=>snapshot.modelLabels?.[id] && snapshot.modelLabels[id] !== id ? `${snapshot.modelLabels[id]} · ${id}` : id));
} },
];
if (scope !== 0) actions.push({ label: 'Quit Beehive', run: () => screen.close() });
screen.show(vanished ? [{ id: selected, label: 'Selection unavailable', detail: 'Unavailable' }, ...rows] : rows, actions, selected);
const notice = scope === 0 ? `Host: ${snapshot.service?.state ?? 'unknown'} · Saved: ${snapshot.settings?.revision ?? 0} · Loaded: ${snapshot.service?.revision ?? 'not confirmed'}\n${snapshot.status}` : snapshot.status;
if (lastStatus !== notice) { lastStatus = notice; screen.notice(notice); }

View File

@@ -18,7 +18,6 @@ export class OpenTuiScreen {
private readonly pane: BoxRenderable;
private readonly detail: TextRenderable;
private readonly scroll: ScrollBoxRenderable;
private readonly controlsFrame: BoxRenderable;
private readonly actions: SelectRenderable;
private readonly status: TextRenderable;
private readonly footer: TextRenderable;
@@ -69,17 +68,14 @@ export class OpenTuiScreen {
else this.notice('Use arrows to select an item in this list. Mouse selection is unavailable.');
} });
this.listFrame.add(this.list);
// Details and Controls share the right column. Controls always occupy a
// bounded bottom region, so agent actions stay reachable and visibly
// focused beside arbitrarily long detail text, for pointer and keyboard.
this.pane = new BoxRenderable(renderer, { flexGrow: 1, height: '100%', flexDirection: 'column' });
// One bordered detail pane owns both the report and its actionable rows.
// Actions have a bounded viewport inside that pane, not a third frame.
this.pane = new BoxRenderable(renderer, { flexGrow: 1, height: '100%', flexDirection: 'column', border: true, title: 'Details' });
body.add(this.pane);
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, border: true, title: 'Details', scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
this.pane.add(this.scroll);
this.detail = new TextRenderable(renderer, { fg, width: '100%', content: 'No items.', selectable: false });
this.scroll.add(this.detail);
this.controlsFrame = new BoxRenderable(renderer, { height: 7, border: true, title: 'Controls' });
this.pane.add(this.controlsFrame);
this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, width: '100%', height: '100%', showDescription: false, showSelectionIndicator: true, showScrollIndicator: true, options: [], onMouseScroll: event => { if (event.scroll?.direction === 'up') this.actions.moveUp(); else if (event.scroll?.direction === 'down') this.actions.moveDown(); }, onMouseDown: event => {
this.focusIndex = 2; this.actions.focus();
const delta = event.y - this.actions.y, visible = Math.max(1, Math.floor(this.actions.height));
@@ -95,7 +91,7 @@ export class OpenTuiScreen {
if (index >= this.commands.length) return;
this.actions.setSelectedIndex(index); this.actions.selectCurrent();
} });
this.controlsFrame.add(this.actions);
this.pane.add(this.actions);
this.status = new TextRenderable(renderer, { fg, height: 2, onMouseDown: () => this.outcome() });
this.root.add(this.status);
this.footer = new TextRenderable(renderer, { fg, height: 1, content: OpenTuiScreen.hintsWide });
@@ -129,7 +125,7 @@ export class OpenTuiScreen {
private focusStyle() {
this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.rows.length ? this.list.getSelectedIndex() + 1 : 0}/${this.rows.length}`;
this.scroll.title = managerSections[this.scope] ?? 'Details';
this.controlsFrame.title = this.actions.focused ? '[Controls]' : 'Controls';
this.pane.title = this.actions.focused ? 'Details · [Actions]' : 'Details · Tab: Actions';
for (const widget of [this.list, this.actions]) { widget.selectedBackgroundColor = widget.focused ? fg : bg; widget.selectedTextColor = widget.focused ? bg : fg; }
}
private narrow = false;
@@ -140,9 +136,8 @@ export class OpenTuiScreen {
setPending(value: boolean) { this.pending = value; }
private formWidth() { return this.scope === 0 ? Math.max(20, this.renderer.width - this.listFrame.width - 2) : Math.min(72, this.renderer.width - 4); }
private formLeft() { return this.scope === 0 ? this.listFrame.width : Math.floor((this.renderer.width - this.formWidth()) / 2); }
/** Bounded Controls height: every action stays reachable through the list's
* own focus scroll, while Details keeps at least a bordered sliver. */
private controlsHeight() { return Math.min(Math.max(7, Math.min(12, this.commands.length + 2)), Math.max(7, this.renderer.height - 10)); }
/** Keep actions reachable within the detail pane without consuming the report. */
private actionHeight() { return Math.min(Math.max(7, Math.min(12, this.commands.length + 2)), Math.max(7, this.renderer.height - 10)) - 2; }
private resize() {
this.heading();
if (this.overlay && this.modal) { this.overlay.width = this.formWidth(); this.overlay.left = this.formLeft(); this.overlay.height = this.compactOverlay ? Math.min(14, this.renderer.height - 4) : this.renderer.height - 4; }
@@ -150,7 +145,7 @@ export class OpenTuiScreen {
this.narrow = this.scope !== 0 && (this.renderer.width < 88 || this.renderer.height < 24);
this.listFrame.visible = !this.narrow || !this.drilled;
this.pane.visible = !this.narrow || this.drilled;
this.controlsFrame.height = this.controlsHeight();
this.actions.height = this.actionHeight();
this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(14, Math.floor(this.renderer.width * .28)));
this.scroll.title = managerSections[this.scope] ?? 'Details';
this.footer.content = this.relayFooter + '\n' + (this.renderer.width >= 70 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow); this.footer.height = 2;
@@ -205,7 +200,7 @@ export class OpenTuiScreen {
this.detail.visible = true;
const commandOptions = actions.map(a => ({ name: clean(a.label + (a.disabled ? ' — unavailable' : '')), description: '' }));
if (JSON.stringify(this.actions.options) !== JSON.stringify(commandOptions)) this.actions.options = commandOptions;
this.controlsFrame.height = this.controlsHeight();
this.actions.height = this.actionHeight();
const options = rows.map(row => ({ name: clean(row.label), description: '', value: row.id }));
if (JSON.stringify(this.list.options) !== JSON.stringify(options)) this.list.options = options;
const index = Math.max(0, rows.findIndex(row => row.id === prior));
@@ -243,7 +238,7 @@ export class OpenTuiScreen {
}
inspect() { if (this.scope === 0) return; const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details', row?.evidence ?? 'No technical details for this item.'); }
private outcome() { this.read('Status · latest message', this.lastNotice || 'No status message yet.'); }
private help() { this.read('Help', 'Tab: switch list and controls. Arrows: select. Enter: open.\nLeft/Right: Host, Agents, Harnesses, Providers.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); }
private help() { this.read('Help', 'Tab: switch list and detail actions. Arrows: select. Enter: open.\nLeft/Right: Host, Agents, Harnesses, Providers.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); }
private read(title: string, content: string) {
if (this.modal || this.closed) return;
const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: this.formLeft(), width: this.formWidth(), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true });

View File

@@ -18,8 +18,14 @@ test('four sections retain exact selections without retargeting removed items',
test('section rows expose selected public fields, not credential references or other controls', () => {
const snapshot: ManagerSnapshot = { local: [{ id: 'host', label: 'This host', detail: '' }], agents: [], status: '', hostRelay: 'wss://fixture.invalid', service: { state: 'stopped' }, settings: { version: 1, revision: 1, agents: [], providers: [{ id: 'p', name: 'Provider A', type: 'openai', endpoint: 'https://api.openai.com/v1', key: { service: 'beehive', account: 'secret-reference' } }], runtimes: [{ id: 'r', name: 'Runtime A', harness: 'buzz-agent', providerId: 'p', model: 'gpt-5', effort: 'high', executable: '/fixture/harness' }] } };
assert.match(sectionRows(snapshot,0)[0]!.detail, /Host name: This host\nRelay URL: wss:\/\/fixture.invalid\nState: stopped/);
assert.equal(sectionRows(snapshot,1).length, 0);
assert.deepEqual(sectionRows(snapshot,1).map(row => row.label), ['Registered agents', ' Register agent', 'Other agents']);
assert.match(sectionRows(snapshot,2)[0]!.detail, /Provider: Provider A\nModel: gpt-5\nEffort: high/);
assert.equal(sectionRows(snapshot,3)[0]!.id,'p');
assert.ok(!JSON.stringify(sectionRows(snapshot,3)).includes('secret-reference'));
});
test('Agents are separated by registered custody, retaining directory order within each group', () => {
const agent = (id: string) => ({ id, label: id, detail: id, revision: 0, configurations: [] });
const snapshot = { local: [], agents: [agent('other'), agent('registered')], status: '', settings: { version: 1, revision: 0, providers: [], runtimes: [], agents: [{ publicKey: 'registered', key: { service: 'beehive', role: 'agent', publicKey: 'registered' }, profileState: 'none' }] } } as ManagerSnapshot;
assert.deepEqual(sectionRows(snapshot, 1).map(row => row.id), ['registered-agents', 'register-agent', 'registered', 'other-agents', 'other']);
});

View File

@@ -68,14 +68,18 @@ test('agent controls stay visible beside long details and user scroll survives u
view.onSelect = id => { routed.push(id); };
const actions = ['Start', 'Stop', 'Restart', 'Move', 'Inspect'].map(label => ({ label, run: () => { runs.push(label); } }));
view.show(rows, actions); await ui.renderOnce();
// Controls are a bounded always-visible region: the action list renders beside
// Actions are a bounded viewport inside the detail pane and render beside
// long detail text, without scrolling the Details pane to reach it.
const initial = ui.captureCharFrame();
assert.ok(initial.includes('Controls')); assert.ok(initial.includes('Restart')); assert.ok(initial.includes('Report line 00'));
assert.ok(!initial.includes('Controls')); assert.ok(initial.includes('Details')); assert.ok(initial.includes('Restart')); assert.ok(initial.includes('Report line 00'));
// The advertised 'a' key focuses controls and the focused region stays on screen.
ui.mockInput.pressKey('a'); await ui.renderOnce();
const focused = ui.captureCharFrame();
assert.ok(focused.includes('[Controls]')); assert.ok(focused.includes('Restart'));
assert.ok(focused.includes('[Actions]')); assert.ok(focused.includes('Restart'));
ui.mockInput.pressArrow('down'); view.show(rows, actions); await ui.renderOnce();
assert.ok(ui.captureCharFrame().includes('▶ Stop'), 'unchanged refresh reset selected detail action');
assert.ok(ui.captureCharFrame().includes('[Actions]'), 'unchanged refresh moved action focus');
ui.mockInput.pressArrow('up');
// User wheel scroll over Details moves the long report off its first lines.
for (let wheel = 0; wheel < 10; wheel++) await ui.mockMouse.scroll(60, 8, 'down');
await ui.renderOnce();
@@ -88,7 +92,7 @@ test('agent controls stay visible beside long details and user scroll survives u
const pushed = ui.captureCharFrame();
assert.ok(!pushed.includes('Report line 00'), 'unchanged snapshot push reset Details scroll');
assert.ok(pushed.includes('Report line 10')); assert.deepEqual(routed, ['agent-one']); assert.deepEqual(runs, []);
// Pointer wheel over the bounded Controls moves the focused action selection.
// Pointer wheel over detail actions moves the focused action selection.
await ui.mockMouse.scroll(40, 22, 'down'); await ui.mockMouse.scroll(40, 22, 'down'); await ui.renderOnce();
assert.ok(ui.captureCharFrame().includes('▶ Restart'));
// A click on a visible control runs exactly that action, never an offset-mapped stranger.
@@ -108,14 +112,14 @@ test('agent controls stay visible beside long details and user scroll survives u
} finally { view.close(); }
});
test('overflowing real Agents controls map every visible click to its own action and keep the tail reachable', async () => {
test('overflowing detail actions map every visible click to its own action and keep the tail reachable', async () => {
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
const view = new OpenTuiScreen(ui.renderer);
try {
const runs: string[] = [];
// The actual signed-in Agents menu from manager-view.ts is the longest list in
// the product: it overflows the bounded Controls region at 100x30.
const labels = ['Register agent', 'Refresh agents', 'Choose runtime…', 'Choose configuration…', 'Start', 'Stop', 'Restart', 'Move…', 'Inspect operations', 'Check operation results', 'Sign out', 'Publish profile or instructions', 'Quit Beehive'];
// Renderer stress fixture, not a mirrored production menu. Production menu
// generation is exercised separately by manager walkthroughs.
const labels = ['Register agent', 'Refresh agents', 'Choose runtime…', 'Choose configuration…', 'Start', 'Stop', 'Restart', 'Move…', 'Inspect operations', 'Check operation results', 'Sign out', 'Publish profile or instructions', 'Last action'];
const actions = labels.map(label => ({ label, run: () => { runs.push(label); } }));
view.show([{ id: 'review-agent', label: 'Review agent', detail: 'Agent detail' }], actions);
await ui.renderOnce();
@@ -132,17 +136,17 @@ test('overflowing real Agents controls map every visible click to its own action
for (let wheel = 0; wheel < 12; wheel++) await ui.mockMouse.scroll(40, 20, 'down');
await ui.renderOnce();
const tailed = ui.captureCharFrame();
assert.ok(tailed.includes('Quit Beehive'), 'keyboard/wheel must keep the tail reachable');
assert.ok(tailed.includes('Last action'), 'keyboard/wheel must keep the tail reachable');
assert.ok(!tailed.includes('Register agent'), 'window should have followed the selection');
await ui.mockMouse.click(40, 22); await ui.renderOnce();
await ui.mockMouse.click(40, 24); await ui.renderOnce();
assert.deepEqual(runs, ['Register agent', 'Check operation results', 'Sign out', 'Quit Beehive']);
assert.deepEqual(runs, ['Register agent', 'Check operation results', 'Sign out', 'Last action']);
// Keyboard selection movement recentres the window on the head again.
for (let step = 0; step < 12; step++) ui.mockInput.pressArrow('up');
await ui.renderOnce();
assert.ok(ui.captureCharFrame().includes('Register agent'));
await ui.mockMouse.click(40, 15); await ui.renderOnce();
assert.deepEqual(runs, ['Register agent', 'Check operation results', 'Sign out', 'Quit Beehive', 'Register agent']);
assert.deepEqual(runs, ['Register agent', 'Check operation results', 'Sign out', 'Last action', 'Register agent']);
} finally { view.close(); }
});

View File

@@ -243,3 +243,18 @@ test('cancelling after host enrollment preserves stopped placement and cannot la
const retry=await request('start',undefined,row().target,row().revision);
assert.equal(retry.state,'registration-required','retry is explicit, never resumes cancelled continuation');
});
test('Other-agent registration requires that exact identity even without a Start continuation', async t => {
const f = await fixture(t);
const other = 'f'.repeat(64);
assert.equal((await f.request('profile-preview', { secret: f.nsec, agent: other })).state, 'failed');
assert.equal(readSettings(f.directory).agents.length, 0);
assert.equal((await f.request('profile-preview', { secret: f.nsec, agent: f.agent })).state, 'completed');
assert.equal((await f.request('register-agent', { secret: f.nsec, agent: other, runtime: f.runtimeId })).state, 'failed');
assert.equal(readSettings(f.directory).agents.length, 0);
assert.equal((await f.request('register-agent', { secret: f.nsec, agent: f.agent, runtime: f.runtimeId })).state, 'completed');
assert.equal(readSettings(f.directory).agents[0]?.publicKey, f.agent);
await f.request('operations');
assert.match(f.snapshot.status, /No saved operations/);
});