From fde263c0df42fceccc442dbb30e1ac682eaa4fec Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Mon, 23 Mar 2026 14:12:08 +0100 Subject: [PATCH 01/14] feat(claw): add channel pairing step to onboarding for Telegram/Discord After provisioning completes, users who selected Telegram or Discord are shown an inline pairing step that polls for incoming pairing requests and lets them approve with a button. Slack skips this step since it has no dmPolicy/pairing mechanism. - New ChannelPairingStep component with channel-specific instructions - ClawDashboard captures selectedChannelId and routes to pairing step - ProvisioningStep accepts dynamic totalSteps (4 or 5) --- .../claw/components/ChannelPairingStep.tsx | 176 ++++++++++++++++++ .../(app)/claw/components/ClawDashboard.tsx | 19 +- .../claw/components/ProvisioningStep.tsx | 8 +- 3 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 src/app/(app)/claw/components/ChannelPairingStep.tsx diff --git a/src/app/(app)/claw/components/ChannelPairingStep.tsx b/src/app/(app)/claw/components/ChannelPairingStep.tsx new file mode 100644 index 000000000..99c561a8b --- /dev/null +++ b/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { Check, Loader2, MessageSquare, ShieldCheck } from 'lucide-react'; +import { toast } from 'sonner'; +import { useKiloClawPairing, useRefreshPairing } from '@/hooks/useKiloClaw'; +import { Button } from '@/components/ui/button'; +import type { ClawMutations } from './claw.types'; +import { OnboardingStepView } from './OnboardingStepView'; +import { TelegramIcon } from './icons/TelegramIcon'; +import { DiscordIcon } from './icons/DiscordIcon'; + +type ChannelPairingStepProps = { + channelId: 'telegram' | 'discord'; + mutations: ClawMutations; + onComplete: () => void; + onSkip: () => void; +}; + +const CHANNEL_META: Record< + 'telegram' | 'discord', + { + label: string; + icon: React.ComponentType<{ className?: string }>; + instruction: string; + } +> = { + telegram: { + label: 'Telegram', + icon: TelegramIcon, + instruction: 'Open Telegram and send any message to your bot.', + }, + discord: { + label: 'Discord', + icon: DiscordIcon, + instruction: 'Open Discord and send a DM to your bot.', + }, +}; + +export function ChannelPairingStep({ + channelId, + mutations, + onComplete, + onSkip, +}: ChannelPairingStepProps) { + const meta = CHANNEL_META[channelId]; + const Icon = meta.icon; + + // Subscribe to the normal pairing query (shared cache with Settings tab) + const { data: pairingData, isLoading } = useKiloClawPairing(true); + + // Bust the KV cache every 5 seconds so new requests appear quickly. + // useRefreshPairing returns a fresh closure each render, so pin it in a ref + // to keep the interval stable. + const refreshPairing = useRefreshPairing(); + const refreshRef = useRef(refreshPairing); + refreshRef.current = refreshPairing; + + useEffect(() => { + refreshRef.current().catch(() => {}); + const id = setInterval(() => { + refreshRef.current().catch(() => {}); + }, 5_000); + return () => clearInterval(id); + }, []); + + // Find the first pairing request matching this channel + const matchingRequest = pairingData?.requests?.find( + (r: { channel: string; code: string; id: string }) => r.channel === channelId + ); + + const isApproving = mutations.approvePairingRequest.isPending; + + function handleApprove(channel: string, code: string) { + mutations.approvePairingRequest.mutate( + { channel, code }, + { + onSuccess: result => { + if (result.success) { + toast.success('Pairing approved'); + onComplete(); + } else { + toast.error(result.message || 'Approval failed'); + } + }, + onError: err => toast.error(`Failed to approve: ${err.message}`), + } + ); + } + + return ( + + {/* Instruction card */} +
+
+ +
+
+

{meta.instruction}

+

+ Your bot will see the message and create a pairing request that appears below. +

+
+
+ + {/* Pairing request area */} +
+
+ + Pairing request +
+ + {matchingRequest ? ( +
+
+ +
+
+ + {matchingRequest.code} + + + {matchingRequest.channel} + +
+

User {matchingRequest.id}

+
+
+ +
+ ) : ( +
+ {isLoading ? ( + + ) : ( + + + + + )} + + Waiting for a message from {meta.label}... + +
+ )} +
+ + {/* Skip link */} + +
+ ); +} diff --git a/src/app/(app)/claw/components/ClawDashboard.tsx b/src/app/(app)/claw/components/ClawDashboard.tsx index 418a964d7..1599d5c5d 100644 --- a/src/app/(app)/claw/components/ClawDashboard.tsx +++ b/src/app/(app)/claw/components/ClawDashboard.tsx @@ -20,6 +20,7 @@ import { InstanceTab } from './InstanceTab'; import { OpenClawButton } from './OpenClawButton'; import { SettingsTab } from './SettingsTab'; import { ChangelogTab } from './ChangelogTab'; +import { ChannelPairingStep } from './ChannelPairingStep'; import { ChannelSelectionStepView } from './ChannelSelectionStep'; import { PermissionStep } from './PermissionStep'; import { ProvisioningStep } from './ProvisioningStep'; @@ -65,10 +66,12 @@ export function ClawDashboard({ const configServiceNudgeVisible = !instanceStatus || instanceYoung; const [onboardingStep, setOnboardingStep] = useState< - 'permissions' | 'channels' | 'provisioning' | 'done' + 'permissions' | 'channels' | 'provisioning' | 'pairing' | 'done' >('permissions'); const [selectedPreset, setSelectedPreset] = useState(null); const [channelTokens, setChannelTokens] = useState | null>(null); + const [selectedChannelId, setSelectedChannelId] = useState(null); + const hasPairingStep = selectedChannelId === 'telegram' || selectedChannelId === 'discord'; const [dirtySecrets, setDirtySecrets] = useState>(new Set()); @@ -160,11 +163,13 @@ export function ClawDashboard({ ) : isNewSetup && onboardingStep === 'channels' ? ( { + onSelect={(channelId, tokens) => { + setSelectedChannelId(channelId); setChannelTokens(tokens); setOnboardingStep('provisioning'); }} onSkip={() => { + setSelectedChannelId(null); setChannelTokens(null); setOnboardingStep('provisioning'); }} @@ -175,7 +180,17 @@ export function ClawDashboard({ channelTokens={channelTokens} instanceRunning={isRunning && gatewayStatus?.state === 'running'} mutations={mutations} + totalSteps={hasPairingStep ? 5 : 4} + onComplete={() => setOnboardingStep(hasPairingStep ? 'pairing' : 'done')} + /> + ) : isNewSetup && + onboardingStep === 'pairing' && + (selectedChannelId === 'telegram' || selectedChannelId === 'discord') ? ( + setOnboardingStep('done')} + onSkip={() => setOnboardingStep('done')} /> ) : isNewSetup ? ( diff --git a/src/app/(app)/claw/components/ProvisioningStep.tsx b/src/app/(app)/claw/components/ProvisioningStep.tsx index c89c1520e..9053f63c0 100644 --- a/src/app/(app)/claw/components/ProvisioningStep.tsx +++ b/src/app/(app)/claw/components/ProvisioningStep.tsx @@ -35,12 +35,14 @@ export function ProvisioningStep({ channelTokens, instanceRunning, mutations, + totalSteps = 4, onComplete, }: { preset: ExecPreset; channelTokens: Record | null; instanceRunning: boolean; mutations: ClawMutations; + totalSteps?: number; onComplete: () => void; }) { const completedRef = useRef(false); @@ -110,7 +112,7 @@ export function ProvisioningStep({ ); }, [instanceRunning, preset]); - return ; + return ; } const PROVISIONING_MESSAGES = [ @@ -129,7 +131,7 @@ const PROVISIONING_MESSAGES = [ ]; /** Pure visual shell — extracted so Storybook can render it without wiring up mutations. */ -export function ProvisioningStepView() { +export function ProvisioningStepView({ totalSteps = 4 }: { totalSteps?: number }) { const [messageIndex, setMessageIndex] = useState(0); const [visible, setVisible] = useState(true); @@ -146,7 +148,7 @@ export function ProvisioningStepView() { return ( From 2df6638e914b661f9488e2ab9525b190a2139f21 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Mon, 23 Mar 2026 14:25:47 +0100 Subject: [PATCH 02/14] feat(claw): add Storybook stories for ChannelPairingStep Extract ChannelPairingStepView as a pure visual component (no hooks) following the ProvisioningStep/ProvisioningStepView pattern. Stories cover both channels, waiting/request/approving/loading states. --- .../claw/components/ChannelPairingStep.tsx | 57 +++++++++++++---- .../claw/ChannelPairingStep.stories.tsx | 64 +++++++++++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 storybook/stories/claw/ChannelPairingStep.stories.tsx diff --git a/src/app/(app)/claw/components/ChannelPairingStep.tsx b/src/app/(app)/claw/components/ChannelPairingStep.tsx index 99c561a8b..b839c9b5c 100644 --- a/src/app/(app)/claw/components/ChannelPairingStep.tsx +++ b/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -10,15 +10,10 @@ import { OnboardingStepView } from './OnboardingStepView'; import { TelegramIcon } from './icons/TelegramIcon'; import { DiscordIcon } from './icons/DiscordIcon'; -type ChannelPairingStepProps = { - channelId: 'telegram' | 'discord'; - mutations: ClawMutations; - onComplete: () => void; - onSkip: () => void; -}; +type PairingChannelId = 'telegram' | 'discord'; const CHANNEL_META: Record< - 'telegram' | 'discord', + PairingChannelId, { label: string; icon: React.ComponentType<{ className?: string }>; @@ -37,15 +32,19 @@ const CHANNEL_META: Record< }, }; +// ── Stateful wrapper (hooks + mutations) ──────────────────────────── + export function ChannelPairingStep({ channelId, mutations, onComplete, onSkip, -}: ChannelPairingStepProps) { - const meta = CHANNEL_META[channelId]; - const Icon = meta.icon; - +}: { + channelId: PairingChannelId; + mutations: ClawMutations; + onComplete: () => void; + onSkip: () => void; +}) { // Subscribe to the normal pairing query (shared cache with Settings tab) const { data: pairingData, isLoading } = useKiloClawPairing(true); @@ -88,6 +87,40 @@ export function ChannelPairingStep({ ); } + return ( + + ); +} + +// ── Pure visual component (no hooks — Storybook-friendly) ─────────── + +type ChannelPairingStepViewProps = { + channelId: PairingChannelId; + matchingRequest: { code: string; channel: string; id: string } | null; + isLoading?: boolean; + isApproving?: boolean; + onApprove?: (channel: string, code: string) => void; + onSkip?: () => void; +}; + +export function ChannelPairingStepView({ + channelId, + matchingRequest, + isLoading = false, + isApproving = false, + onApprove, + onSkip, +}: ChannelPairingStepViewProps) { + const meta = CHANNEL_META[channelId]; + const Icon = meta.icon; + return ( handleApprove(matchingRequest.channel, matchingRequest.code)} + onClick={() => onApprove?.(matchingRequest.channel, matchingRequest.code)} disabled={isApproving} > {isApproving ? ( diff --git a/storybook/stories/claw/ChannelPairingStep.stories.tsx b/storybook/stories/claw/ChannelPairingStep.stories.tsx new file mode 100644 index 000000000..78bdf2882 --- /dev/null +++ b/storybook/stories/claw/ChannelPairingStep.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { ChannelPairingStepView } from '@/app/(app)/claw/components/ChannelPairingStep'; + +const meta: Meta = { + title: 'Claw/ChannelPairingStep', + component: ChannelPairingStepView, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const TelegramWaiting: Story = { + args: { + channelId: 'telegram', + matchingRequest: null, + }, +}; + +export const DiscordWaiting: Story = { + args: { + channelId: 'discord', + matchingRequest: null, + }, +}; + +export const TelegramWithRequest: Story = { + args: { + channelId: 'telegram', + matchingRequest: { code: '3YPKLDPP', channel: 'telegram', id: '829104561' }, + }, +}; + +export const DiscordWithRequest: Story = { + args: { + channelId: 'discord', + matchingRequest: { code: 'K7WMRX2Q', channel: 'discord', id: '491028374165' }, + }, +}; + +export const Approving: Story = { + args: { + channelId: 'telegram', + matchingRequest: { code: '3YPKLDPP', channel: 'telegram', id: '829104561' }, + isApproving: true, + }, +}; + +export const Loading: Story = { + args: { + channelId: 'telegram', + matchingRequest: null, + isLoading: true, + }, +}; From 7dd50f07769454764d122369ab73bd429f233590 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Mon, 23 Mar 2026 14:51:22 +0100 Subject: [PATCH 03/14] refactor(claw): redesign pairing step waiting state to match mockup Replace instruction card + dashed polling box with a centered spinner, heading, and subtitle matching the provisioning step style. Title now reads 'Pair your Telegram/Discord bot'. Skip button uses subdued opacity. Step indicator stays left-aligned. --- .../claw/components/ChannelPairingStep.tsx | 186 ++++++++++-------- .../claw/ChannelPairingStep.stories.tsx | 8 - 2 files changed, 104 insertions(+), 90 deletions(-) diff --git a/src/app/(app)/claw/components/ChannelPairingStep.tsx b/src/app/(app)/claw/components/ChannelPairingStep.tsx index b839c9b5c..6029e063f 100644 --- a/src/app/(app)/claw/components/ChannelPairingStep.tsx +++ b/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useRef } from 'react'; -import { Check, Loader2, MessageSquare, ShieldCheck } from 'lucide-react'; +import { Check, Loader2, MessageSquare } from 'lucide-react'; import { toast } from 'sonner'; import { useKiloClawPairing, useRefreshPairing } from '@/hooks/useKiloClaw'; import { Button } from '@/components/ui/button'; @@ -23,12 +23,14 @@ const CHANNEL_META: Record< telegram: { label: 'Telegram', icon: TelegramIcon, - instruction: 'Open Telegram and send any message to your bot.', + instruction: + 'Open Telegram and send any message to your bot. The bot will reply with a pairing request — we\u2019ll pick it up automatically.', }, discord: { label: 'Discord', icon: DiscordIcon, - instruction: 'Open Discord and send a DM to your bot.', + instruction: + 'Open Discord and send a DM to your bot. The bot will reply with a pairing request — we\u2019ll pick it up automatically.', }, }; @@ -46,7 +48,7 @@ export function ChannelPairingStep({ onSkip: () => void; }) { // Subscribe to the normal pairing query (shared cache with Settings tab) - const { data: pairingData, isLoading } = useKiloClawPairing(true); + const { data: pairingData } = useKiloClawPairing(true); // Bust the KV cache every 5 seconds so new requests appear quickly. // useRefreshPairing returns a fresh closure each render, so pin it in a ref @@ -91,7 +93,6 @@ export function ChannelPairingStep({ void; onSkip?: () => void; @@ -113,97 +111,121 @@ type ChannelPairingStepViewProps = { export function ChannelPairingStepView({ channelId, matchingRequest, - isLoading = false, isApproving = false, onApprove, onSkip, }: ChannelPairingStepViewProps) { const meta = CHANNEL_META[channelId]; - const Icon = meta.icon; + + if (matchingRequest) { + return ( + +
+
+ +
+
+ + {matchingRequest.code} + + + {matchingRequest.channel} + +
+

User {matchingRequest.id}

+
+
+ +
+ + +
+ ); + } return ( - {/* Instruction card */} -
-
- +
+
+ + + + +
-
-

{meta.instruction}

-

- Your bot will see the message and create a pairing request that appears below. -

-
-
- {/* Pairing request area */} -
-
- - Pairing request +
+

+ Waiting for you to message the bot... +

+

+ This page will update as soon as the bot responds +

- {matchingRequest ? ( -
-
- -
-
- - {matchingRequest.code} - - - {matchingRequest.channel} - -
-

User {matchingRequest.id}

-
-
- -
- ) : ( -
- {isLoading ? ( - - ) : ( - - - - - )} - - Waiting for a message from {meta.label}... - -
- )} +
- - {/* Skip link */} - ); } diff --git a/storybook/stories/claw/ChannelPairingStep.stories.tsx b/storybook/stories/claw/ChannelPairingStep.stories.tsx index 78bdf2882..3bff9eebd 100644 --- a/storybook/stories/claw/ChannelPairingStep.stories.tsx +++ b/storybook/stories/claw/ChannelPairingStep.stories.tsx @@ -54,11 +54,3 @@ export const Approving: Story = { isApproving: true, }, }; - -export const Loading: Story = { - args: { - channelId: 'telegram', - matchingRequest: null, - isLoading: true, - }, -}; From d26d85f19569c7d16b97c540758656860ff53fb9 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Mon, 23 Mar 2026 15:30:48 +0100 Subject: [PATCH 04/14] refactor(claw): redesign pairing request view to match mockup Show pairing code prominently with user ID, full-width authorize button, and decline link. Remove dead imports and JSX comments. Add cursor-pointer to all interactive elements. --- .../claw/components/ChannelPairingStep.tsx | 100 ++++++++---------- 1 file changed, 47 insertions(+), 53 deletions(-) diff --git a/src/app/(app)/claw/components/ChannelPairingStep.tsx b/src/app/(app)/claw/components/ChannelPairingStep.tsx index 6029e063f..63112fc8e 100644 --- a/src/app/(app)/claw/components/ChannelPairingStep.tsx +++ b/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -1,41 +1,28 @@ 'use client'; import { useEffect, useRef } from 'react'; -import { Check, Loader2, MessageSquare } from 'lucide-react'; +import { CheckCircle2, Loader2, Send, XCircle } from 'lucide-react'; import { toast } from 'sonner'; import { useKiloClawPairing, useRefreshPairing } from '@/hooks/useKiloClaw'; import { Button } from '@/components/ui/button'; import type { ClawMutations } from './claw.types'; import { OnboardingStepView } from './OnboardingStepView'; -import { TelegramIcon } from './icons/TelegramIcon'; -import { DiscordIcon } from './icons/DiscordIcon'; type PairingChannelId = 'telegram' | 'discord'; -const CHANNEL_META: Record< - PairingChannelId, - { - label: string; - icon: React.ComponentType<{ className?: string }>; - instruction: string; - } -> = { +const CHANNEL_META: Record = { telegram: { label: 'Telegram', - icon: TelegramIcon, instruction: - 'Open Telegram and send any message to your bot. The bot will reply with a pairing request — we\u2019ll pick it up automatically.', + 'Open Telegram and send any message to your bot. The bot will reply with a pairing request \u2014 we\u2019ll pick it up automatically.', }, discord: { label: 'Discord', - icon: DiscordIcon, instruction: - 'Open Discord and send a DM to your bot. The bot will reply with a pairing request — we\u2019ll pick it up automatically.', + 'Open Discord and send a DM to your bot. The bot will reply with a pairing request \u2014 we\u2019ll pick it up automatically.', }, }; -// ── Stateful wrapper (hooks + mutations) ──────────────────────────── - export function ChannelPairingStep({ channelId, mutations, @@ -47,12 +34,8 @@ export function ChannelPairingStep({ onComplete: () => void; onSkip: () => void; }) { - // Subscribe to the normal pairing query (shared cache with Settings tab) const { data: pairingData } = useKiloClawPairing(true); - // Bust the KV cache every 5 seconds so new requests appear quickly. - // useRefreshPairing returns a fresh closure each render, so pin it in a ref - // to keep the interval stable. const refreshPairing = useRefreshPairing(); const refreshRef = useRef(refreshPairing); refreshRef.current = refreshPairing; @@ -65,7 +48,6 @@ export function ChannelPairingStep({ return () => clearInterval(id); }, []); - // Find the first pairing request matching this channel const matchingRequest = pairingData?.requests?.find( (r: { channel: string; code: string; id: string }) => r.channel === channelId ); @@ -123,45 +105,57 @@ export function ChannelPairingStepView({ currentStep={5} totalSteps={5} title={`Pair your ${meta.label} bot`} - description="A pairing request was detected — approve it to link your account." - contentClassName="gap-8" + description={meta.instruction} + contentClassName="gap-6" > -
-
- -
-
- - {matchingRequest.code} - - - {matchingRequest.channel} - -
-

User {matchingRequest.id}

-
+
+
+ + + Pairing request received + +
+ +
+ {meta.label} user ID + {matchingRequest.id} +
+ +
+ + Pairing code + + + {matchingRequest.code} +
-
+

+ A user on {meta.label} is requesting access to your bot. Only approve if you initiated + this. +

+ + + ); From 932ebba5316fb85b4d3d13ba68e690ebfef1f82f Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 24 Mar 2026 10:28:53 +0100 Subject: [PATCH 05/14] fix(claw): switch pairing poll from fixed interval to sequential fetch-then-wait Replaces setInterval(5s) with a sequential loop that waits 1s after each fetch completes, preventing request pileup on slow responses. --- .../claw/components/ChannelPairingStep.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/(app)/claw/components/ChannelPairingStep.tsx b/src/app/(app)/claw/components/ChannelPairingStep.tsx index 63112fc8e..c5b2a7ccd 100644 --- a/src/app/(app)/claw/components/ChannelPairingStep.tsx +++ b/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -41,11 +41,18 @@ export function ChannelPairingStep({ refreshRef.current = refreshPairing; useEffect(() => { - refreshRef.current().catch(() => {}); - const id = setInterval(() => { - refreshRef.current().catch(() => {}); - }, 5_000); - return () => clearInterval(id); + let cancelled = false; + async function poll() { + while (!cancelled) { + await refreshRef.current().catch(() => {}); + if (cancelled) break; + await new Promise(r => setTimeout(r, 1_000)); + } + } + poll(); + return () => { + cancelled = true; + }; }, []); const matchingRequest = pairingData?.requests?.find( @@ -211,7 +218,6 @@ export function ChannelPairingStepView({ This page will update as soon as the bot responds

-