Big changes and fixes. Uses action history. Improve role selection. Remove unused logs

This commit is contained in:
2026-02-08 15:41:14 +00:00
parent da096af0fa
commit df57f1b9ad
16 changed files with 1250 additions and 1181 deletions

View File

@@ -7,6 +7,7 @@ import { useActionWizard } from './useActionWizard.js';
// Steps
import { InfoStep } from './steps/InfoStep.js';
import { RoleSelectStep } from './steps/RoleSelectStep.js';
import { VariablesStep } from './steps/VariablesStep.js';
import { InputsStep } from './steps/InputsStep.js';
import { ReviewStep } from './steps/ReviewStep.js';
@@ -21,6 +22,19 @@ export function ActionWizardScreen(): React.ReactElement {
// Tab to cycle between content area and button bar
if (key.tab) {
if (wizard.focusArea === 'content') {
// Within the role-select step, tab through roles first
if (
wizard.currentStepData?.type === 'role-select' &&
wizard.availableRoles.length > 0
) {
if (
wizard.selectedRoleIndex <
wizard.availableRoles.length - 1
) {
wizard.setSelectedRoleIndex((prev) => prev + 1);
return;
}
}
// Within the inputs step, tab through UTXOs first
if (
wizard.currentStepData?.type === 'inputs' &&
@@ -47,11 +61,27 @@ export function ActionWizardScreen(): React.ReactElement {
wizard.setFocusArea('content');
wizard.setFocusedInput(0);
wizard.setSelectedUtxoIndex(0);
wizard.setSelectedRoleIndex(0);
}
}
return;
}
// Arrow keys for role selection in the content area
if (
wizard.focusArea === 'content' &&
wizard.currentStepData?.type === 'role-select'
) {
if (key.upArrow) {
wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedRoleIndex((p) =>
Math.min(wizard.availableRoles.length - 1, p + 1)
);
}
return;
}
// Arrow keys for UTXO selection in the content area
if (
wizard.focusArea === 'content' &&
@@ -131,6 +161,16 @@ export function ActionWizardScreen(): React.ReactElement {
actionName={wizard.actionName}
/>
);
case 'role-select':
return (
<RoleSelectStep
template={wizard.template!}
actionIdentifier={wizard.actionIdentifier!}
availableRoles={wizard.availableRoles}
selectedRoleIndex={wizard.selectedRoleIndex}
focusArea={wizard.focusArea}
/>
);
case 'variables':
return (
<VariablesStep
@@ -189,8 +229,8 @@ export function ActionWizardScreen(): React.ReactElement {
{logoSmall} - Action Wizard
</Text>
<Text color={colors.textMuted}>
{wizard.template?.name} {">"} {wizard.actionName} (as{" "}
{wizard.roleIdentifier})
{wizard.template?.name} {">"} {wizard.actionName}
{wizard.roleIdentifier ? ` (as ${wizard.roleIdentifier})` : ''}
</Text>
</Box>

View File

@@ -0,0 +1,120 @@
/**
* Role Selection Step - Allows the user to choose which role they want
* to take for the selected action.
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import type { XOTemplate } from '@xo-cash/types';
import type { FocusArea } from '../types.js';
interface RoleSelectStepProps {
/** The loaded template definition. */
template: XOTemplate;
/** The selected action identifier. */
actionIdentifier: string;
/** Role identifiers available for this action. */
availableRoles: string[];
/** The currently focused role index. */
selectedRoleIndex: number;
/** Whether the content area or button bar is focused. */
focusArea: FocusArea;
}
/**
* Displays the available roles for the selected action and
* lets the user navigate between them with arrow keys.
*/
export function RoleSelectStep({
template,
actionIdentifier,
availableRoles,
selectedRoleIndex,
focusArea,
}: RoleSelectStepProps): React.ReactElement {
const action = template.actions?.[actionIdentifier];
return (
<Box flexDirection="column">
<Text color={colors.text} bold>
Select your role for this action:
</Text>
{/* Action info */}
{action && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.textMuted}>
{action.description || 'No description available'}
</Text>
</Box>
)}
{/* Role list */}
<Box
marginTop={1}
flexDirection="column"
borderStyle="single"
borderColor={colors.border}
paddingX={1}
>
{availableRoles.length === 0 ? (
<Text color={colors.textMuted}>No roles available</Text>
) : (
availableRoles.map((roleId, index) => {
const isCursor =
selectedRoleIndex === index && focusArea === 'content';
const roleDef = template.roles?.[roleId];
const actionRole = action?.roles?.[roleId];
const requirements = actionRole?.requirements;
return (
<Box key={roleId} flexDirection="column" marginY={0}>
<Text
color={isCursor ? colors.focus : colors.text}
bold={isCursor}
>
{isCursor ? '▸ ' : ' '}
{roleDef?.name || roleId}
</Text>
{/* Show role description indented below the name */}
{roleDef?.description && (
<Text color={colors.textMuted}>
{' '}
{roleDef.description}
</Text>
)}
{/* Show a brief summary of requirements */}
{requirements && (
<Box flexDirection="row" paddingLeft={4}>
{requirements.variables && requirements.variables.length > 0 && (
<Text color={colors.textMuted} dimColor>
{requirements.variables.length} variable
{requirements.variables.length !== 1 ? 's' : ''}
{' '}
</Text>
)}
{requirements.slots && requirements.slots.min > 0 && (
<Text color={colors.textMuted} dimColor>
{requirements.slots.min} input slot
{requirements.slots.min !== 1 ? 's' : ''}
</Text>
)}
</Box>
)}
</Box>
);
})
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
: Navigate Next: Confirm selection
</Text>
</Box>
</Box>
);
}

View File

@@ -1,4 +1,5 @@
export * from './InfoStep.js';
export * from './RoleSelectStep.js';
export * from './VariablesStep.js';
export * from './InputsStep.js';
export * from './ReviewStep.js';

View File

@@ -1,6 +1,6 @@
import type { XOTemplate } from '@xo-cash/types';
export type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
export type StepType = 'info' | 'role-select' | 'variables' | 'inputs' | 'review' | 'publish';
export interface WizardStep {
name: string;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { formatSatoshis } from '../../theme.js';
@@ -18,11 +18,29 @@ export function useActionWizard() {
const { setStatus } = useStatus();
// ── Navigation data ──────────────────────────────────────────────
// Role is no longer passed via navigation — it is selected in the wizard.
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const roleIdentifier = navData.roleIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
// ── Role selection state ────────────────────────────────────────
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
/**
* Roles that can start this action, derived from the template's
* `start` entries filtered to the current action.
*/
const availableRoles = useMemo(() => {
if (!template || !actionIdentifier) return [];
const starts = template.start ?? [];
const roleIds = starts
.filter((s) => s.action === actionIdentifier)
.map((s) => s.role);
// Deduplicate while preserving order
return [...new Set(roleIds)];
}, [template, actionIdentifier]);
// ── Wizard state ─────────────────────────────────────────────────
const [steps, setSteps] = useState<WizardStep[]>([]);
const [currentStep, setCurrentStep] = useState(0);
@@ -61,51 +79,55 @@ export function useActionWizard() {
currentStepData?.type === 'variables' && focusArea === 'content';
// ── Initialization ───────────────────────────────────────────────
// Builds the wizard steps dynamically based on the selected role.
// Re-runs when roleIdentifier changes to add role-specific steps.
useEffect(() => {
if (!template || !actionIdentifier || !roleIdentifier) {
if (!template || !actionIdentifier) {
showError('Missing wizard data');
goBack();
return;
}
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleIdentifier];
const requirements = role?.requirements;
// const wizardSteps: WizardStep[] = [{ name: 'Welcome', type: 'info' }];
const wizardSteps: WizardStep[] = [];
// Add variables step if needed
if (requirements?.variables && requirements.variables.length > 0) {
wizardSteps.push({ name: 'Variables', type: 'variables' });
// Always start with role selection
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
const varInputs = requirements.variables.map((varId) => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || 'string',
hint: varDef?.hint,
value: '',
};
});
setVariables(varInputs);
// Add role-specific steps only after role is selected
if (roleIdentifier) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleIdentifier];
const requirements = role?.requirements;
// Add variables step if needed
if (requirements?.variables && requirements.variables.length > 0) {
wizardSteps.push({ name: 'Variables', type: 'variables' });
const varInputs = requirements.variables.map((varId) => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || 'string',
hint: varDef?.hint,
value: '',
};
});
setVariables(varInputs);
}
// Add inputs step if role requires slots (funding inputs)
if (requirements?.slots && requirements.slots.min > 0) {
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
}
}
// Add inputs step if role requires slots (funding inputs)
// Slots indicate the role needs to provide transaction inputs/outputs
if (requirements?.slots && requirements.slots.min > 0) {
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
}
// Add review step
// Always add review and publish at the end
wizardSteps.push({ name: 'Review', type: 'review' });
// Add publish step
wizardSteps.push({ name: 'Publish', type: 'publish' });
setSteps(wizardSteps);
setStatus(`${actionIdentifier}/${roleIdentifier}`);
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
}, [
template,
actionIdentifier,
@@ -115,6 +137,17 @@ export function useActionWizard() {
setStatus,
]);
// ── Auto-advance from role-select after role is chosen ──────────
// This runs after the main useEffect has rebuilt steps, ensuring
// we advance to the correct step (variables, inputs, or review).
useEffect(() => {
if (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
setCurrentStep(1);
setFocusArea('content');
setFocusedInput(0);
}
}, [roleIdentifier, currentStep, steps]);
// ── Update a single variable value ───────────────────────────────
const updateVariable = useCallback((index: number, value: string) => {
setVariables((prev) => {
@@ -255,103 +288,108 @@ export function useActionWizard() {
]);
// ── Create invitation and persist variables ─────────────────────
const createInvitationWithVariables = useCallback(async () => {
if (
!templateIdentifier ||
!actionIdentifier ||
!roleIdentifier ||
!template ||
!appService
) {
return;
}
/**
* Creates an invitation, optionally persists variable values,
* and adds template-required outputs.
*
* Accepts an explicit `roleId` to avoid stale-closure issues
* when called immediately after setting role state.
*
* Does NOT advance the wizard step — the caller is responsible.
*
* @returns `true` on success, `false` on failure.
*/
const createInvitationWithVariables = useCallback(
async (roleId?: string): Promise<boolean> => {
const effectiveRole = roleId ?? roleIdentifier;
setIsProcessing(true);
setStatus('Creating invitation...');
if (
!templateIdentifier ||
!actionIdentifier ||
!effectiveRole ||
!template ||
!appService
) {
return false;
}
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
setIsProcessing(true);
setStatus('Creating invitation...');
// Wrap and track
const invitationInstance =
await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
const variableData = variables.map((v) => {
const isNumeric =
['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
// Add template-required outputs for the current role
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
// Wrap and track
const invitationInstance =
await appService.createInvitation(xoInvitation);
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
const outputsToAdd = transaction.outputs.map(
(outputId: string) => ({
outputIdentifier: outputId,
})
// Persist variable values
if (variables.length > 0) {
const variableData = variables.map((v) => {
const isNumeric =
['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier: effectiveRole,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
// Add template-required outputs for the current role
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = transaction.outputs.map(
(outputId: string) => ({
outputIdentifier: outputId,
})
);
await invitationInstance.addOutputs(outputsToAdd);
inv = invitationInstance.data;
}
setInvitation(inv);
setStatus('Invitation created');
return true;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
);
await invitationInstance.addOutputs(outputsToAdd);
inv = invitationInstance.data;
return false;
} finally {
setIsProcessing(false);
}
setInvitation(inv);
// Advance and optionally kick off UTXO loading
const nextStepType = steps[currentStep + 1]?.type;
if (nextStepType === 'inputs') {
setCurrentStep((prev) => prev + 1);
setTimeout(() => loadAvailableUtxos(), 100);
} else {
setCurrentStep((prev) => prev + 1);
}
setStatus('Invitation created');
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsProcessing(false);
}
}, [
templateIdentifier,
actionIdentifier,
roleIdentifier,
template,
variables,
appService,
steps,
currentStep,
showError,
setStatus,
loadAvailableUtxos,
]);
},
[
templateIdentifier,
actionIdentifier,
roleIdentifier,
template,
variables,
appService,
showError,
setStatus,
]
);
// ── Add selected inputs + change output to the invitation ───────
const addInputsAndOutputs = useCallback(async () => {
@@ -465,6 +503,41 @@ export function useActionWizard() {
const stepType = currentStepData?.type;
// ── Role selection ──────────────────────────────────────────
if (stepType === 'role-select') {
const selectedRole = availableRoles[selectedRoleIndex];
if (!selectedRole) {
showError('Please select a role');
return;
}
// Check what the selected role requires
const act = template?.actions?.[actionIdentifier ?? ''];
const role = act?.roles?.[selectedRole];
const requirements = role?.requirements;
const hasVariables =
requirements?.variables && requirements.variables.length > 0;
const hasSlots = requirements?.slots && requirements.slots.min > 0;
// If there is no variables step, the invitation must be created now
// because the variables step would normally handle it.
if (!hasVariables) {
const success = await createInvitationWithVariables(selectedRole);
if (!success) return;
// If we're going to the inputs step, load UTXOs
if (hasSlots) {
setTimeout(() => loadAvailableUtxos(), 100);
}
}
// Set role — this triggers the useEffect to rebuild steps and advance
setRoleIdentifier(selectedRole);
return;
}
// ── Variables ───────────────────────────────────────────────
if (stepType === 'variables') {
const emptyVars = variables.filter(
(v) => !v.value || v.value.trim() === ''
@@ -475,30 +548,53 @@ export function useActionWizard() {
);
return;
}
await createInvitationWithVariables();
// Create the invitation and persist the variable values
const success = await createInvitationWithVariables();
if (!success) return;
// Advance, optionally kicking off UTXO loading
const nextStepType = steps[currentStep + 1]?.type;
if (nextStepType === 'inputs') {
setCurrentStep((prev) => prev + 1);
setTimeout(() => loadAvailableUtxos(), 100);
} else {
setCurrentStep((prev) => prev + 1);
}
setFocusArea('content');
setFocusedInput(0);
return;
}
// ── Inputs ──────────────────────────────────────────────────
if (stepType === 'inputs') {
await addInputsAndOutputs();
return;
}
// ── Review ──────────────────────────────────────────────────
if (stepType === 'review') {
await publishInvitation();
return;
}
// ── Generic advance (e.g. publish → done) ───────────────────
setCurrentStep((prev) => prev + 1);
setFocusArea('content');
setFocusedInput(0);
}, [
currentStep,
steps.length,
steps,
currentStepData,
availableRoles,
selectedRoleIndex,
template,
actionIdentifier,
variables,
showError,
createInvitationWithVariables,
loadAvailableUtxos,
addInputsAndOutputs,
publishInvitation,
]);
@@ -529,6 +625,11 @@ export function useActionWizard() {
action,
actionName,
// Role selection
availableRoles,
selectedRoleIndex,
setSelectedRoleIndex,
// Steps
steps,
currentStep,