From 06d26b168a8d6726b62529f11dad4135f92754cf Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 7 May 2026 11:43:59 -0700 Subject: [PATCH 1/4] feat(kiloclaw): Add read only info screen about their bot's email address feat(kiloclaw): drop channels and pairing from active onboarding flow --- .../ClawOnboardingFakeWalkthrough.tsx | 98 +++------------ .../ClawOnboardingFlow.state.test.ts | 119 +++--------------- .../components/ClawOnboardingFlow.state.ts | 77 +++--------- .../claw/components/ClawOnboardingFlow.tsx | 104 +++++---------- .../claw/components/InboundEmailStep.tsx | 94 ++++++++++++++ 5 files changed, 178 insertions(+), 314 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx index 0cc4696fc..77fefe65b 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx @@ -7,28 +7,24 @@ import { CLAW_ONBOARDING_FAKE_STEPS, type ClawOnboardingRenderStep, getClawOnboardingStepProgress, - isPairingChannel, type OnboardingStep, - type PairingChannelId, } from './ClawOnboardingFlow.state'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { BotIdentityStep } from './BotIdentityStep'; -import { ChannelPairingStepView } from './ChannelPairingStep'; -import { ChannelSelectionStepView } from './ChannelSelectionStep'; import { ClawConfigServiceBanner } from './ClawConfigServiceBanner'; import { ClawHeader } from './ClawHeader'; import { CalendarConnectStepView } from './CalendarConnectStep'; +import { InboundEmailStepView } from './InboundEmailStep'; import { ClawSetupCompleteStep, ClawSetupErrorStep } from './ClawOnboardingFlow'; import { ProvisioningStepView } from './ProvisioningStep'; const FAKE_STEP_LABELS: Record = { identity: 'Identity', calendar: 'Calendar', - channels: 'Channels', + email: 'Inbound Email', provisioning: 'Provisioning', - pairing: 'Pairing', complete: 'Complete', error: 'Error', }; @@ -87,7 +83,6 @@ export function ClawOnboardingFakeWalkthrough({ basePath: string; }) { const [step, setStep] = useState(initialStep); - const [selectedChannelId, setSelectedChannelId] = useState('telegram'); useEffect(() => { setStep(initialStep); @@ -95,11 +90,7 @@ export function ClawOnboardingFakeWalkthrough({ if (process.env.NODE_ENV === 'production') return null; - const pairingChannelId: PairingChannelId = isPairingChannel(selectedChannelId) - ? selectedChannelId - : 'telegram'; - const hasPairingStep = isPairingChannel(selectedChannelId); - const stepProgress = getFakeStepProgress(step, hasPairingStep); + const stepProgress = getFakeStepProgress(step); return (
@@ -116,7 +107,7 @@ export function ClawOnboardingFakeWalkthrough({ Development-only fake KiloClaw onboarding. This walkthrough does not call billing, - provisioning, gateway, Fly, or pairing services. + provisioning, or gateway services. @@ -125,9 +116,6 @@ export function ClawOnboardingFakeWalkthrough({ {renderFakeStep({ step, setStep, - selectedChannelId, - setSelectedChannelId, - pairingChannelId, stepProgress, basePath, })} @@ -174,27 +162,20 @@ type StepProgress = ReturnType; type RenderFakeStepInput = { step: ClawOnboardingRenderStep; setStep: (step: ClawOnboardingRenderStep) => void; - selectedChannelId: string | null; - setSelectedChannelId: (channelId: string | null) => void; - pairingChannelId: PairingChannelId; stepProgress: StepProgress; basePath: string; }; -function getFakeStepProgress( - step: ClawOnboardingRenderStep, - hasPairingStep: boolean -): StepProgress { - return getClawOnboardingStepProgress(getFakeOnboardingStep(step), hasPairingStep); +function getFakeStepProgress(step: ClawOnboardingRenderStep): StepProgress { + return getClawOnboardingStepProgress(getFakeOnboardingStep(step)); } function getFakeOnboardingStep(step: ClawOnboardingRenderStep): OnboardingStep { switch (step) { case 'identity': case 'calendar': - case 'channels': + case 'email': case 'provisioning': - case 'pairing': return step; case 'complete': case 'error': @@ -202,15 +183,7 @@ function getFakeOnboardingStep(step: ClawOnboardingRenderStep): OnboardingStep { } } -function renderFakeStep({ - step, - setStep, - selectedChannelId, - setSelectedChannelId, - pairingChannelId, - stepProgress, - basePath, -}: RenderFakeStepInput) { +function renderFakeStep({ step, setStep, stepProgress, basePath }: RenderFakeStepInput) { switch (step) { case 'identity': { return setStep('calendar')} />; @@ -223,26 +196,19 @@ function renderFakeStep({ isConnected={false} connectedAccountEmail={null} readyToConnect={true} - onConnectClick={() => setStep('channels')} - onSkip={() => setStep('channels')} - onContinue={() => setStep('channels')} + onConnectClick={() => setStep('email')} + onSkip={() => setStep('email')} + onContinue={() => setStep('email')} /> ); } - case 'channels': { + case 'email': { return ( - { - setSelectedChannelId(channelId); - setStep('provisioning'); - }} - onSkip={() => { - setSelectedChannelId(null); - setStep('provisioning'); - }} + address="operator@inbound.claw.kilocode.ai" + enabled={true} + onContinue={() => setStep('provisioning')} /> ); } @@ -255,40 +221,14 @@ function renderFakeStep({

Fake mode keeps this spinner static so you can inspect the final provisioning state.

-
- - -
+
); } - case 'pairing': { - return ( - setStep('complete')} - onSkip={() => setStep('complete')} - /> - ); - } case 'complete': return ; case 'error': diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts index d26d33706..cb1b16800 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts @@ -7,7 +7,6 @@ import { getClawOnboardingFlowState, getClawOnboardingStepProgress, hasPopulatedStatus, - isPairingChannel, } from './ClawOnboardingFlow.state'; function createStatus(status: KiloClawDashboardStatus['status']): KiloClawDashboardStatus { @@ -67,7 +66,6 @@ function createInput( createSetupStarted: false, onboardingStep: 'identity', hasBotIdentity: false, - selectedChannelId: null, gatewayState: null, ...overrides, }; @@ -80,13 +78,6 @@ describe('ClawOnboardingFlow state machine', () => { expect(hasPopulatedStatus(createStatus('running'))).toBe(true); }); - test('detects channels that need a pairing step', () => { - expect(isPairingChannel('telegram')).toBe(true); - expect(isPairingChannel('discord')).toBe(true); - expect(isPairingChannel('slack')).toBe(false); - expect(isPairingChannel(null)).toBe(false); - }); - test('renders identity before provisioning starts', () => { const state = getClawOnboardingFlowState(createInput()); @@ -136,11 +127,11 @@ describe('ClawOnboardingFlow state machine', () => { getClawOnboardingFlowState( createInput({ createSetupStarted: true, - onboardingStep: 'channels', + onboardingStep: 'email', hasBotIdentity: true, }) ).renderStep - ).toBe('channels'); + ).toBe('email'); expect( getClawOnboardingFlowState( createInput({ @@ -150,16 +141,6 @@ describe('ClawOnboardingFlow state machine', () => { }) ).renderStep ).toBe('provisioning'); - expect( - getClawOnboardingFlowState( - createInput({ - createSetupStarted: true, - onboardingStep: 'pairing', - hasBotIdentity: true, - selectedChannelId: 'telegram', - }) - ).renderStep - ).toBe('pairing'); expect( getClawOnboardingFlowState( createInput({ @@ -170,68 +151,33 @@ describe('ClawOnboardingFlow state machine', () => { ).toBe('complete'); }); - test('uses five steps only when the selected channel requires pairing', () => { - const pairingTelegram = getClawOnboardingFlowState( - createInput({ selectedChannelId: 'telegram' }) - ); - expect(pairingTelegram.totalSteps).toBe(5); - expect(pairingTelegram.currentStep).toBe(1); - - const pairingDiscord = getClawOnboardingFlowState( - createInput({ selectedChannelId: 'discord' }) - ); - expect(pairingDiscord.totalSteps).toBe(5); - expect(pairingDiscord.currentStep).toBe(1); - - const noPairingSlack = getClawOnboardingFlowState(createInput({ selectedChannelId: 'slack' })); - expect(noPairingSlack.totalSteps).toBe(4); - expect(noPairingSlack.currentStep).toBe(1); - + test('the active wizard has four steps regardless of input', () => { + // Channels and pairing were removed from the active wizard. The + // counter is now constant at 4 (identity, calendar, email, + // provisioning). Future Interests step (PR-4) will renumber. const defaultState = getClawOnboardingFlowState(createInput()); expect(defaultState.totalSteps).toBe(4); expect(defaultState.currentStep).toBe(1); }); test('getClawOnboardingStepProgress returns correct live current and total steps', () => { - expect(getClawOnboardingStepProgress('identity', false)).toEqual({ + expect(getClawOnboardingStepProgress('identity')).toEqual({ currentStep: 1, totalSteps: 4, }); - expect(getClawOnboardingStepProgress('calendar', false)).toEqual({ + expect(getClawOnboardingStepProgress('calendar')).toEqual({ currentStep: 2, totalSteps: 4, }); - expect(getClawOnboardingStepProgress('channels', false)).toEqual({ + expect(getClawOnboardingStepProgress('email')).toEqual({ currentStep: 3, totalSteps: 4, }); - expect(getClawOnboardingStepProgress('provisioning', false)).toEqual({ + expect(getClawOnboardingStepProgress('provisioning')).toEqual({ currentStep: 4, totalSteps: 4, }); - expect(getClawOnboardingStepProgress('done', false)).toEqual({ currentStep: 4, totalSteps: 4 }); - - expect(getClawOnboardingStepProgress('identity', true)).toEqual({ - currentStep: 1, - totalSteps: 5, - }); - expect(getClawOnboardingStepProgress('calendar', true)).toEqual({ - currentStep: 2, - totalSteps: 5, - }); - expect(getClawOnboardingStepProgress('channels', true)).toEqual({ - currentStep: 3, - totalSteps: 5, - }); - expect(getClawOnboardingStepProgress('provisioning', true)).toEqual({ - currentStep: 4, - totalSteps: 5, - }); - expect(getClawOnboardingStepProgress('pairing', true)).toEqual({ - currentStep: 5, - totalSteps: 5, - }); - expect(getClawOnboardingStepProgress('done', true)).toEqual({ currentStep: 5, totalSteps: 5 }); + expect(getClawOnboardingStepProgress('done')).toEqual({ currentStep: 4, totalSteps: 4 }); }); test.each(CLAW_ONBOARDING_PROVISIONING_STATUSES)( @@ -365,21 +311,18 @@ describe('ClawOnboardingFlow state machine', () => { expect(state.renderStep).toBe('calendar'); }); - test('honors channels onboarding step in post-provisioning mode', () => { - // After the OAuth resume lands on calendar and the user clicks - // Continue/Skip to advance, onboardingStep flips to 'channels'. The - // post-prov branch should respect that rather than auto-completing. + test('honors email onboarding step in post-provisioning mode', () => { const state = getClawOnboardingFlowState( createInput({ mode: 'post-provisioning', status: createStatus('running'), - onboardingStep: 'channels', + onboardingStep: 'email', hasBotIdentity: true, gatewayState: 'running', }) ); - expect(state.renderStep).toBe('channels'); + expect(state.renderStep).toBe('email'); }); test('honors provisioning onboarding step in post-provisioning mode', () => { @@ -395,21 +338,6 @@ describe('ClawOnboardingFlow state machine', () => { expect(state.renderStep).toBe('provisioning'); }); - test('honors pairing onboarding step in post-provisioning mode for pairing channels', () => { - const state = getClawOnboardingFlowState( - createInput({ - mode: 'post-provisioning', - status: createStatus('running'), - onboardingStep: 'pairing', - selectedChannelId: 'telegram', - hasBotIdentity: true, - gatewayState: 'running', - }) - ); - - expect(state.renderStep).toBe('pairing'); - }); - test('renders complete in post-provisioning mode once the machine is running', () => { const state = getClawOnboardingFlowState( createInput({ @@ -450,15 +378,6 @@ describe('ClawOnboardingFlow state machine', () => { }); test('normalizes impossible local wizard states to the earliest safe prerequisite', () => { - expect( - getClawOnboardingFlowState( - createInput({ - createSetupStarted: true, - onboardingStep: 'channels', - hasBotIdentity: true, - }) - ).renderStep - ).toBe('channels'); expect( getClawOnboardingFlowState( createInput({ @@ -468,15 +387,5 @@ describe('ClawOnboardingFlow state machine', () => { }) ).renderStep ).toBe('provisioning'); - expect( - getClawOnboardingFlowState( - createInput({ - createSetupStarted: true, - onboardingStep: 'pairing', - hasBotIdentity: true, - selectedChannelId: 'slack', - }) - ).renderStep - ).toBe('complete'); }); }); diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts index a03b3eaca..7b4147fa1 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.ts @@ -6,20 +6,13 @@ export type PopulatedClawStatus = KiloClawDashboardStatus & { export type ClawOnboardingMode = 'create-first' | 'post-provisioning'; -export type OnboardingStep = - | 'identity' - | 'calendar' - | 'channels' - | 'provisioning' - | 'pairing' - | 'done'; +export type OnboardingStep = 'identity' | 'calendar' | 'email' | 'provisioning' | 'done'; export const CLAW_ONBOARDING_WIZARD_STEPS = [ 'identity', 'calendar', - 'channels', + 'email', 'provisioning', - 'pairing', ] as const satisfies OnboardingStep[]; export type ClawOnboardingWizardStep = (typeof CLAW_ONBOARDING_WIZARD_STEPS)[number]; @@ -27,12 +20,14 @@ export type ClawOnboardingWizardStep = (typeof CLAW_ONBOARDING_WIZARD_STEPS)[num export type ClawOnboardingRenderStep = | 'identity' | 'calendar' - | 'channels' + | 'email' | 'provisioning' - | 'pairing' | 'complete' | 'error'; +// Kept for parity with `ChannelPairingStep.tsx`, which currently isn't wired +// into the wizard but stays in the codebase in case channel pairing comes +// back. Not used in the active onboarding flow. export type PairingChannelId = 'telegram' | 'discord'; export const FAKE_ONBOARDING_STEP_PARAM = 'fakeOnboardingStep'; @@ -40,9 +35,8 @@ export const FAKE_ONBOARDING_STEP_PARAM = 'fakeOnboardingStep'; export const CLAW_ONBOARDING_FAKE_STEPS = [ 'identity', 'calendar', - 'channels', + 'email', 'provisioning', - 'pairing', 'complete', 'error', ] satisfies ClawOnboardingRenderStep[]; @@ -72,7 +66,6 @@ export type ClawOnboardingFlowStateInput = { setupFailed?: boolean; onboardingStep: OnboardingStep; hasBotIdentity: boolean; - selectedChannelId: string | null; gatewayState?: GatewayProcessStatusResponse['state'] | null; debugLogSource?: string; }; @@ -85,7 +78,6 @@ export type ClawOnboardingFlowState = { instanceRunning: boolean; createSetupActive: boolean; postProvisioningReady: boolean; - hasPairingStep: boolean; currentStep: number; totalSteps: number; }; @@ -107,12 +99,12 @@ export function isClawOnboardingErrorStatus(status: PopulatedClawStatus['status' return false; } -export function getClawOnboardingStepProgress( - step: OnboardingStep, - hasPairingStep: boolean -): { currentStep: number; totalSteps: number } { +export function getClawOnboardingStepProgress(step: OnboardingStep): { + currentStep: number; + totalSteps: number; +} { const wizardSteps: readonly OnboardingStep[] = CLAW_ONBOARDING_WIZARD_STEPS; - const totalSteps = hasPairingStep ? wizardSteps.length : wizardSteps.length - 1; + const totalSteps = wizardSteps.length; if (step === 'done') { return { currentStep: totalSteps, totalSteps }; @@ -131,7 +123,6 @@ export function getClawOnboardingFlowState({ setupFailed = false, onboardingStep, hasBotIdentity, - selectedChannelId, gatewayState, debugLogSource = 'default', }: ClawOnboardingFlowStateInput): ClawOnboardingFlowState { @@ -142,8 +133,7 @@ export function getClawOnboardingFlowState({ const postProvisioningReady = isRunning; const createSetupActive = mode === 'create-first' && (createSetupStarted || instanceStatus !== null); - const hasPairingStep = isPairingChannel(selectedChannelId); - const { currentStep, totalSteps } = getClawOnboardingStepProgress(onboardingStep, hasPairingStep); + const { currentStep, totalSteps } = getClawOnboardingStepProgress(onboardingStep); const renderStepDecision = getRenderStepDecision({ mode, createSetupStarted, @@ -152,7 +142,6 @@ export function getClawOnboardingFlowState({ postProvisioningReady, onboardingStep, hasBotIdentity, - hasPairingStep, }); const flowState = { renderStep: renderStepDecision.renderStep, @@ -162,7 +151,6 @@ export function getClawOnboardingFlowState({ instanceRunning, createSetupActive, postProvisioningReady, - hasPairingStep, currentStep, totalSteps, } satisfies ClawOnboardingFlowState; @@ -174,7 +162,6 @@ export function getClawOnboardingFlowState({ setupFailed, onboardingStep, hasBotIdentity, - selectedChannelId, gatewayState, debugLogSource, instanceStatus, @@ -183,7 +170,6 @@ export function getClawOnboardingFlowState({ instanceRunning, createSetupActive, postProvisioningReady, - hasPairingStep, currentStep, totalSteps, renderStepDecision, @@ -198,7 +184,6 @@ type RenderStepInput = Pick< > & { instanceStatus: PopulatedClawStatus | null; postProvisioningReady: boolean; - hasPairingStep: boolean; }; type RenderStepDecision = { @@ -214,7 +199,6 @@ type ClawOnboardingFlowDebugLogInput = ClawOnboardingFlowStateInput & { instanceRunning: boolean; createSetupActive: boolean; postProvisioningReady: boolean; - hasPairingStep: boolean; currentStep: number; totalSteps: number; renderStepDecision: RenderStepDecision; @@ -237,7 +221,6 @@ function getRenderStepDecision({ postProvisioningReady, onboardingStep, hasBotIdentity, - hasPairingStep, }: RenderStepInput): RenderStepDecision { if (instanceStatus && isClawOnboardingErrorStatus(instanceStatus.status)) { return { @@ -257,20 +240,17 @@ function getRenderStepDecision({ // After a full-page reload (e.g. the Google OAuth round-trip), the // wizard often remounts in post-provisioning mode because the instance // row is now visible — but the user is still mid-wizard. Honor any - // explicit wizard step rather than auto-routing them past it. Without - // this, advancing from calendar → channels → provisioning would fall - // through to the default post-prov branch and skip channels, pairing, - // and the provisioning UX entirely. + // explicit wizard step rather than auto-routing them past it. if (onboardingStep === 'calendar') { return { renderStep: 'calendar', reason: 'calendar resume requested; honor it even in post-provisioning mode', }; } - if (onboardingStep === 'channels') { + if (onboardingStep === 'email') { return { - renderStep: 'channels', - reason: 'wizard resume on channels; honor it even in post-provisioning mode', + renderStep: 'email', + reason: 'wizard resume on email; honor it even in post-provisioning mode', }; } if (onboardingStep === 'provisioning') { @@ -279,12 +259,6 @@ function getRenderStepDecision({ reason: 'wizard resume on provisioning; honor it even in post-provisioning mode', }; } - if (onboardingStep === 'pairing' && hasPairingStep) { - return { - renderStep: 'pairing', - reason: 'wizard resume on pairing; honor it even in post-provisioning mode', - }; - } if (postProvisioningReady) { return { renderStep: 'complete', @@ -327,10 +301,10 @@ function getRenderStepDecision({ }; } - if (onboardingStep === 'channels') { + if (onboardingStep === 'email') { return { - renderStep: 'channels', - reason: 'stored onboarding step is channels', + renderStep: 'email', + reason: 'stored onboarding step is email', }; } @@ -341,13 +315,6 @@ function getRenderStepDecision({ }; } - if (onboardingStep === 'pairing' && hasPairingStep) { - return { - renderStep: 'pairing', - reason: 'stored onboarding step is pairing and the selected channel requires pairing', - }; - } - return { renderStep: 'complete', reason: 'no earlier step matched, so the flow falls through to complete', @@ -361,7 +328,6 @@ function logClawOnboardingFlowStateDecision({ setupFailed, onboardingStep, hasBotIdentity, - selectedChannelId, gatewayState, debugLogSource, instanceStatus, @@ -370,7 +336,6 @@ function logClawOnboardingFlowStateDecision({ instanceRunning, createSetupActive, postProvisioningReady, - hasPairingStep, currentStep, totalSteps, renderStepDecision, @@ -384,7 +349,6 @@ function logClawOnboardingFlowStateDecision({ setupFailed, onboardingStep, hasBotIdentity, - selectedChannelId, gatewayState: gatewayState ?? null, status: status?.status ?? null, hasStatusResponse: status !== undefined, @@ -400,7 +364,6 @@ function logClawOnboardingFlowStateDecision({ instanceRunning, createSetupActive, postProvisioningReady, - hasPairingStep, currentStep, totalSteps, }, diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx index 9fbab074e..2e0dd95ad 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx @@ -19,8 +19,7 @@ import { useGatewayUrl } from '../hooks/useGatewayUrl'; import { BillingWrapper } from './billing/BillingWrapper'; import { BotIdentityStep } from './BotIdentityStep'; import { CalendarConnectStepView } from './CalendarConnectStep'; -import { ChannelPairingStep } from './ChannelPairingStep'; -import { ChannelSelectionStepView } from './ChannelSelectionStep'; +import { InboundEmailStepView } from './InboundEmailStep'; import { ClawContextProvider, useClawContext } from './ClawContext'; import { ClawConfigServiceBanner } from './ClawConfigServiceBanner'; import { ClawHeader } from './ClawHeader'; @@ -29,7 +28,6 @@ import { DEFAULT_BOT_IDENTITY, DEFAULT_ONBOARDING_EXEC_PRESET } from './claw.typ import type { BotIdentity, ExecPreset } from './claw.types'; import { getClawOnboardingFlowState, - isPairingChannel, type ClawOnboardingMode, type OnboardingStep, } from './ClawOnboardingFlow.state'; @@ -128,12 +126,11 @@ function ClawOnboardingFlowInner({ }); const selectedPreset: ExecPreset = DEFAULT_ONBOARDING_EXEC_PRESET; const [botIdentity, setBotIdentity] = useState(null); - const [channelTokens, setChannelTokens] = useState | null>(null); - const [selectedChannelId, setSelectedChannelId] = useState(null); const [localCreateSetupStarted, setLocalCreateSetupStarted] = useState(false); const [onboardingSaveSession, setOnboardingSaveSession] = useState(0); const hasCapturedIdentityView = useRef(false); const hasCapturedCalendarView = useRef(false); + const hasCapturedEmailView = useRef(false); const hasCapturedDoneView = useRef(false); const createSetupStarted = createFlowStarted || localCreateSetupStarted; @@ -144,7 +141,6 @@ function ClawOnboardingFlowInner({ setupFailed, onboardingStep, hasBotIdentity: botIdentity !== null, - selectedChannelId, }; const preGatewayFlowState = getClawOnboardingFlowState({ ...stateInput, @@ -173,15 +169,17 @@ function ClawOnboardingFlowInner({ const pathname = usePathname(); const searchParams = useSearchParams(); - // Save bot identity, exec preset, and channel tokens as soon as the instance - // row exists. This closes the tab-close window where customizations entered - // during the provisioning spinner could otherwise be lost with the unmounted - // ProvisioningStep. + // Save bot identity and exec preset as soon as the instance row exists. + // This closes the tab-close window where customizations entered during the + // provisioning spinner could otherwise be lost with the unmounted + // ProvisioningStep. Channel tokens used to live here too; they're now + // dropped from the active flow but useOnboardingSaves still accepts the + // arg as null so we don't need to touch the hook. const onboardingSaves = useOnboardingSaves({ hasInstance: flowState.instanceStatus !== null, botIdentity, selectedPreset, - channelTokens, + channelTokens: null, resetKey: `${onboardingSaveSession}:${ flowState.instanceStatus?.instanceId ?? flowState.instanceStatus?.sandboxId ?? 'pending' }`, @@ -205,6 +203,13 @@ function ClawOnboardingFlowInner({ posthog?.capture('claw_setup_calendar_viewed'); }, [flowState.renderStep, posthog]); + // Same pattern for the inbound email step. + useEffect(() => { + if (flowState.renderStep !== 'email' || hasCapturedEmailView.current) return; + hasCapturedEmailView.current = true; + posthog?.capture('claw_setup_email_viewed'); + }, [flowState.renderStep, posthog]); + useEffect(() => { if ( mode !== 'post-provisioning' || @@ -220,8 +225,6 @@ function ClawOnboardingFlowInner({ const resetWizardSelections = useCallback(() => { setOnboardingStep('identity'); setBotIdentity(null); - setChannelTokens(null); - setSelectedChannelId(null); }, []); const handleCreateFlowStarted = useCallback(() => { @@ -428,9 +431,8 @@ function ClawOnboardingFlowInner({ const isConnected = Boolean(flowState.instanceStatus?.googleOAuthConnected); const connectedEmail = flowState.instanceStatus?.googleOAuthAccountEmail ?? null; - function advanceToChannels() { - posthog?.capture('claw_setup_channels_viewed'); - setOnboardingStep('channels'); + function advanceToEmail() { + setOnboardingStep('email'); } return ( @@ -446,40 +448,29 @@ function ClawOnboardingFlowInner({ }} onSkip={() => { posthog?.capture('claw_setup_calendar_completed', { connected: false, skipped: true }); - advanceToChannels(); + advanceToEmail(); }} onContinue={() => { posthog?.capture('claw_setup_calendar_completed', { connected: true, skipped: false }); - advanceToChannels(); + advanceToEmail(); }} /> ); } - function renderChannelsStep() { + function renderEmailStep() { return ( - { - posthog?.capture('claw_setup_channels_completed', { - channel: channelId, - skipped: false, - }); - posthog?.capture('claw_setup_provisioning_viewed'); - setSelectedChannelId(channelId); - setChannelTokens(tokens); - setOnboardingStep('provisioning'); + address={flowState.instanceStatus?.inboundEmailAddress ?? null} + enabled={flowState.instanceStatus?.inboundEmailEnabled ?? false} + onCopyClick={() => { + posthog?.capture('claw_setup_email_address_copied'); }} - onSkip={() => { - posthog?.capture('claw_setup_channels_completed', { - channel: null, - skipped: true, - }); + onContinue={() => { + posthog?.capture('claw_setup_email_completed'); posthog?.capture('claw_setup_provisioning_viewed'); - setSelectedChannelId(null); - setChannelTokens(null); setOnboardingStep('provisioning'); }} /> @@ -493,7 +484,7 @@ function ClawOnboardingFlowInner({ // takes over). When a wizard resume after an OAuth round-trip reaches // the provisioning step explicitly (onboardingStep === 'provisioning'), // use the full ProvisioningStep so its onComplete fires and the user - // actually advances to pairing/done instead of getting stuck. + // actually advances to done instead of getting stuck. if (mode === 'post-provisioning' && onboardingStep !== 'provisioning') return ( { posthog?.capture('claw_setup_provisioned'); - posthog?.capture( - flowState.hasPairingStep ? 'claw_setup_pairing_viewed' : 'claw_setup_done_viewed' - ); - setOnboardingStep(flowState.hasPairingStep ? 'pairing' : 'done'); - }} - /> - ); - } - - function renderPairingStep() { - if (!isPairingChannel(selectedChannelId)) return renderCompleteStep(); - - return ( - { - posthog?.capture('claw_setup_pairing_completed', { - channel: selectedChannelId, - skipped: false, - }); - posthog?.capture('claw_setup_done_viewed'); - setOnboardingStep('done'); - }} - onSkip={() => { - posthog?.capture('claw_setup_pairing_completed', { - channel: selectedChannelId, - skipped: true, - }); posthog?.capture('claw_setup_done_viewed'); setOnboardingStep('done'); }} @@ -564,12 +524,10 @@ function ClawOnboardingFlowInner({ return renderIdentityStep(); case 'calendar': return renderCalendarStep(); - case 'channels': - return renderChannelsStep(); + case 'email': + return renderEmailStep(); case 'provisioning': return renderProvisioningStep(); - case 'pairing': - return renderPairingStep(); case 'complete': return renderCompleteStep(); case 'error': diff --git a/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx b/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx new file mode 100644 index 000000000..753da2fdb --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useState } from 'react'; +import { Check, Copy, Mail } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { OnboardingStepView } from './OnboardingStepView'; + +type InboundEmailStepViewProps = { + currentStep: number; + totalSteps: number; + /** Inbound alias from `KiloClawDashboardStatus.inboundEmailAddress`. */ + address: string | null; + /** `KiloClawDashboardStatus.inboundEmailEnabled`. */ + enabled: boolean; + onContinue: () => void; + onCopyClick?: () => void; +}; + +export function InboundEmailStepView({ + currentStep, + totalSteps, + address, + enabled, + onContinue, + onCopyClick, +}: InboundEmailStepViewProps) { + const [copied, setCopied] = useState(false); + const ready = Boolean(address) && enabled; + + function handleCopy() { + if (!address) return; + void navigator.clipboard + .writeText(address) + .then(() => toast.success('Inbound email address copied')) + .catch(() => toast.error('Failed to copy inbound email address')); + onCopyClick?.(); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + +
+
+ + Inbound Address + +
+
+ + {ready ? ( + + {address} + + ) : ( + + {enabled ? 'Setting up your inbox…' : 'Inbound email is not enabled.'} + + )} +
+ +
+
+ +
+ +
+
+
+ ); +} From 747e4245b68c110adcd26a682d362ed332304183 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 7 May 2026 13:10:35 -0700 Subject: [PATCH 2/4] fix(claw): gate inbound-email Continue + correct copy handler --- .../ClawOnboardingFakeWalkthrough.tsx | 1 + .../claw/components/ClawOnboardingFlow.tsx | 14 +++++- .../claw/components/InboundEmailStep.tsx | 46 ++++++++++++++----- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx index 77fefe65b..4cb8e919d 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx @@ -208,6 +208,7 @@ function renderFakeStep({ step, setStep, stepProgress, basePath }: RenderFakeSte {...stepProgress} address="operator@inbound.claw.kilocode.ai" enabled={true} + loading={false} onContinue={() => setStep('provisioning')} /> ); diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx index 2e0dd95ad..0f81b5ee2 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.tsx @@ -459,12 +459,22 @@ function ClawOnboardingFlowInner({ } function renderEmailStep() { + // Loading = platform status hasn't returned yet (instanceStatus null) OR + // the alias hasn't propagated despite the feature being enabled. Either + // way, the address can't be displayed; gate Continue so users don't + // skip the screen during the brief window before the alias appears. + const persisted = flowState.instanceStatus; + const inboundEmailAddress = persisted?.inboundEmailAddress ?? null; + const inboundEmailEnabled = persisted?.inboundEmailEnabled ?? false; + const loading = persisted === null || (inboundEmailEnabled && inboundEmailAddress === null); + return ( { posthog?.capture('claw_setup_email_address_copied'); }} diff --git a/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx b/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx index 753da2fdb..ead25b50b 100644 --- a/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx +++ b/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx @@ -13,6 +13,13 @@ type InboundEmailStepViewProps = { address: string | null; /** `KiloClawDashboardStatus.inboundEmailEnabled`. */ enabled: boolean; + /** + * True when the platform status query hasn't returned yet (or has returned + * with `enabled: true` but no address). Distinguishes the loading case from + * the genuine "feature is disabled for this instance" case so we can show + * the right copy and prevent the user from skipping the screen entirely. + */ + loading: boolean; onContinue: () => void; onCopyClick?: () => void; }; @@ -22,21 +29,29 @@ export function InboundEmailStepView({ totalSteps, address, enabled, + loading, onContinue, onCopyClick, }: InboundEmailStepViewProps) { const [copied, setCopied] = useState(false); const ready = Boolean(address) && enabled; + const canContinue = !loading; - function handleCopy() { + async function handleCopy() { if (!address) return; - void navigator.clipboard - .writeText(address) - .then(() => toast.success('Inbound email address copied')) - .catch(() => toast.error('Failed to copy inbound email address')); - onCopyClick?.(); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (!navigator.clipboard?.writeText) { + toast.error('Clipboard copy is not available in this browser'); + return; + } + try { + await navigator.clipboard.writeText(address); + toast.success('Inbound email address copied'); + onCopyClick?.(); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Failed to copy inbound email address'); + } } return ( @@ -62,7 +77,11 @@ export function InboundEmailStepView({ ) : ( - {enabled ? 'Setting up your inbox…' : 'Inbound email is not enabled.'} + {loading + ? 'Setting up your inbox…' + : enabled + ? 'Setting up your inbox…' + : 'Inbound email is not enabled.'} )} @@ -84,8 +103,13 @@ export function InboundEmailStepView({
-
From a723ad9097c29b7157ad50053072d40fa8ffde94 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 7 May 2026 13:16:53 -0700 Subject: [PATCH 3/4] fix(claw): clear inbound-email copy timer on unmount and reset --- .../claw/components/InboundEmailStep.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx b/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx index ead25b50b..1328cd958 100644 --- a/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx +++ b/apps/web/src/app/(app)/claw/components/InboundEmailStep.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Check, Copy, Mail } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -37,6 +37,20 @@ export function InboundEmailStepView({ const ready = Boolean(address) && enabled; const canContinue = !loading; + // Track the "reset Copied state after 2s" timer so we can clear it on + // unmount (or when the user clicks Copy again before the previous timer + // fires). Without this, navigating away within the 2s window leaves a + // pending setCopied(false) call against an unmounted component. + const copyResetTimerRef = useRef(null); + useEffect(() => { + return () => { + if (copyResetTimerRef.current !== null) { + window.clearTimeout(copyResetTimerRef.current); + copyResetTimerRef.current = null; + } + }; + }, []); + async function handleCopy() { if (!address) return; if (!navigator.clipboard?.writeText) { @@ -48,7 +62,13 @@ export function InboundEmailStepView({ toast.success('Inbound email address copied'); onCopyClick?.(); setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + if (copyResetTimerRef.current !== null) { + window.clearTimeout(copyResetTimerRef.current); + } + copyResetTimerRef.current = window.setTimeout(() => { + setCopied(false); + copyResetTimerRef.current = null; + }, 2000); } catch { toast.error('Failed to copy inbound email address'); } From 825a97b151e9f22c8b5e77474a6a226ee01bfc6d Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Thu, 7 May 2026 13:33:48 -0700 Subject: [PATCH 4/4] fix(claw): use placeholder domain for fake-walkthrough email --- .../app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx index 4cb8e919d..f4adaffc7 100644 --- a/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx @@ -206,7 +206,7 @@ function renderFakeStep({ step, setStep, stepProgress, basePath }: RenderFakeSte return ( setStep('provisioning')}