diff --git a/apps/mobile/.rnstorybook/.gitignore b/apps/mobile/.rnstorybook/.gitignore new file mode 100644 index 00000000000..575262128f0 --- /dev/null +++ b/apps/mobile/.rnstorybook/.gitignore @@ -0,0 +1 @@ +storybook.requires.ts diff --git a/apps/mobile/.rnstorybook/StorybookRouterProvider.tsx b/apps/mobile/.rnstorybook/StorybookRouterProvider.tsx new file mode 100644 index 00000000000..29d1a8bd415 --- /dev/null +++ b/apps/mobile/.rnstorybook/StorybookRouterProvider.tsx @@ -0,0 +1,57 @@ +import { + PreviewRouteContext, + type PreviewRouteContextType, +} from "expo-router/build/link/preview/PreviewRouteContext"; +import { + LinkingContext, + NavigationContainer, + NavigationContainerRefContext, + ThemeProvider, + UNSTABLE_UnhandledLinkingContext as UnhandledLinkingContext, +} from "expo-router/react-navigation"; +import { type PropsWithChildren, useContext } from "react"; +import { NAV_THEME } from "@/lib/theme"; + +import { + storybookLinkingContext, + storybookLinkingOptions, +} from "./router/LinkingContext"; +import { storybookUnhandledLinkingContext } from "./router/UnhandledLinkingContext"; + +const storybookRoute = { + pathname: "/storybook", + params: {}, + segments: ["storybook"], +} satisfies PreviewRouteContextType; + +export function StorybookRouterProvider({ children }: PropsWithChildren) { + const navigationRef = useContext(NavigationContainerRefContext); + + const content = ( + + + + + {children} + + + + + ); + + if (navigationRef) { + return content; + } + + return ( + + {content} + + ); +} diff --git a/apps/mobile/.rnstorybook/index.tsx b/apps/mobile/.rnstorybook/index.tsx new file mode 100644 index 00000000000..2ec6aa32348 --- /dev/null +++ b/apps/mobile/.rnstorybook/index.tsx @@ -0,0 +1,26 @@ +// Entry point used by app/_layout.tsx when EXPO_PUBLIC_STORYBOOK=true. +// `storybook.requires` is generated by `sb-rn-get-stories` at dev-time — +// see the "storybook" script in package.json. It is gitignored. +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { StorybookRouterProvider } from "./StorybookRouterProvider"; +import { view } from "./storybook.requires"; + +// Storybook v9 react-native requires the `storage` adapter to be passed +// explicitly when `shouldPersistSelection: true` — it does not auto-detect +// AsyncStorage. Without this, attempts to read the persisted story selection +// throw `TypeError: Cannot read property 'getItem' of undefined`. +const StorybookUIRoot = view.getStorybookUI({ + shouldPersistSelection: true, + storage: { + getItem: AsyncStorage.getItem, + setItem: AsyncStorage.setItem, + }, +}); + +export default function StorybookRoot() { + return ( + + + + ); +} diff --git a/apps/mobile/.rnstorybook/main.js b/apps/mobile/.rnstorybook/main.js new file mode 100644 index 00000000000..a1ef6908a82 --- /dev/null +++ b/apps/mobile/.rnstorybook/main.js @@ -0,0 +1,13 @@ +/** @type {import('@storybook/react-native').StorybookConfig} */ +const main = { + stories: [ + "./stories/**/*.stories.?(ts|tsx|js|jsx)", + "../components/**/*.stories.?(ts|tsx|js|jsx)", + ], + addons: [ + "@storybook/addon-ondevice-controls", + "@storybook/addon-ondevice-actions", + ], +}; + +module.exports = main; diff --git a/apps/mobile/.rnstorybook/mocks/tty.js b/apps/mobile/.rnstorybook/mocks/tty.js new file mode 100644 index 00000000000..f053ebf7976 --- /dev/null +++ b/apps/mobile/.rnstorybook/mocks/tty.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/apps/mobile/.rnstorybook/preview.tsx b/apps/mobile/.rnstorybook/preview.tsx new file mode 100644 index 00000000000..aea298c6f57 --- /dev/null +++ b/apps/mobile/.rnstorybook/preview.tsx @@ -0,0 +1,35 @@ +import { PortalHost } from "@rn-primitives/portal"; +import type { Preview } from "@storybook/react-native"; +import { View } from "react-native"; +import { cn } from "@/lib/utils"; + +// NavigationContainer is provided by StorybookRouterProvider — +// do NOT add one here or SDK 56's expo-router compat check will fail. +const preview: Preview = { + decorators: [ + (Story, context) => { + const isFullscreen = context.parameters?.layout === "fullscreen"; + return ( + + + + + ); + }, + ], + parameters: { + controls: { + matchers: { + color: /(background|color|foreground)$/i, + date: /Date$/i, + }, + }, + moduleMock: { + mockingPairedModules: { + tty: () => require("./mocks/tty"), + }, + }, + }, +}; + +export default preview; diff --git a/apps/mobile/.rnstorybook/router/LinkingContext.ts b/apps/mobile/.rnstorybook/router/LinkingContext.ts new file mode 100644 index 00000000000..accda4e5bdc --- /dev/null +++ b/apps/mobile/.rnstorybook/router/LinkingContext.ts @@ -0,0 +1,23 @@ +import type { + LinkingOptions, + ParamListBase, +} from "expo-router/react-navigation"; +import * as React from "react"; + +export const storybookLinkingOptions: LinkingOptions = { + enabled: false, + prefixes: [], + config: { + screens: { + Storybook: "*", + }, + }, +}; + +export const storybookLinkingContext = { + options: storybookLinkingOptions, +}; + +export const LinkingContext = React.createContext(storybookLinkingContext); + +LinkingContext.displayName = "LinkingContext"; diff --git a/apps/mobile/.rnstorybook/router/UnhandledLinkingContext.ts b/apps/mobile/.rnstorybook/router/UnhandledLinkingContext.ts new file mode 100644 index 00000000000..d122a7052c4 --- /dev/null +++ b/apps/mobile/.rnstorybook/router/UnhandledLinkingContext.ts @@ -0,0 +1,19 @@ +import * as React from "react"; + +export type StorybookUnhandledLinkingContextValue = { + lastUnhandledLink: string | undefined; + setLastUnhandledLink: (lastUnhandledUrl: string | undefined) => void; +}; + +export const storybookUnhandledLinkingContext: StorybookUnhandledLinkingContextValue = + { + lastUnhandledLink: undefined, + setLastUnhandledLink: () => {}, + }; + +export const UnhandledLinkingContext = + React.createContext( + storybookUnhandledLinkingContext, + ); + +UnhandledLinkingContext.displayName = "UnhandledLinkingContext"; diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 283df545853..aead9f5a6b0 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,6 +1,51 @@ import "react-native-get-random-values"; // MUST BE FIRST IMPORT import "../global.css"; +import { + Geist_400Regular, + Geist_500Medium, + Geist_600SemiBold, + Geist_700Bold, + useFonts, +} from "@expo-google-fonts/geist"; +import { + GeistMono_400Regular, + GeistMono_500Medium, +} from "@expo-google-fonts/geist-mono"; +import * as SplashScreen from "expo-splash-screen"; +import { useEffect } from "react"; import { RootLayout } from "@/screens/RootLayout"; -export default RootLayout; +SplashScreen.preventAutoHideAsync().catch(() => { + /* splash already hidden — fine to swallow */ +}); + +const StorybookRoot = + process.env.EXPO_PUBLIC_STORYBOOK === "true" + ? require("../.rnstorybook").default + : null; + +export default function App() { + const [fontsLoaded, fontError] = useFonts({ + Geist_400Regular, + Geist_500Medium, + Geist_600SemiBold, + Geist_700Bold, + GeistMono_400Regular, + GeistMono_500Medium, + }); + + useEffect(() => { + if (fontsLoaded || fontError) { + if (fontError) { + console.error("Font loading failed:", fontError); + } + SplashScreen.hideAsync().catch(() => {}); + } + }, [fontsLoaded, fontError]); + + if (!fontsLoaded && !fontError) return null; + + const Root = StorybookRoot ?? RootLayout; + return ; +} diff --git a/apps/mobile/components/AppHeader/AppHeader.stories.tsx b/apps/mobile/components/AppHeader/AppHeader.stories.tsx new file mode 100644 index 00000000000..c2554e260d0 --- /dev/null +++ b/apps/mobile/components/AppHeader/AppHeader.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { Settings } from "lucide-react-native"; +import { AppHeader } from "./AppHeader"; + +const meta: Meta = { + title: "Molecules/AppHeader", + component: AppHeader, + parameters: { + docs: { + description: { + component: + "Top navigation header on every chat view. Three-region flex: leading back IconButton (optional) + centered title/subtitle + trailing actions IconButton (optional). `isScrolled` adds a layered shadow for separation from scrolling content. Composes first-party IconButton + Text.", + }, + }, + layout: "fullscreen", + }, + args: { + title: "Fix auth bug", + subtitle: "superset · main", + showBack: true, + showActions: true, + isScrolled: false, + }, + argTypes: { + title: { control: "text" }, + subtitle: { control: "text" }, + showBack: { control: "boolean" }, + showActions: { control: "boolean" }, + isScrolled: { control: "boolean", description: "Adds 1px bottom shadow" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoSubtitle: Story = { + args: { subtitle: undefined }, +}; + +export const NoBack: Story = { + args: { showBack: false, title: "Sessions" }, +}; + +export const SimpleNoActions: Story = { + args: { subtitle: undefined, showActions: false }, +}; + +export const Scrolled: Story = { + args: { isScrolled: true }, +}; + +export const CustomActionsIcon: Story = { + args: { actionsIcon: Settings, actionsAccessibilityLabel: "Settings" }, +}; diff --git a/apps/mobile/components/AppHeader/AppHeader.tsx b/apps/mobile/components/AppHeader/AppHeader.tsx new file mode 100644 index 00000000000..ba1e4b8be83 --- /dev/null +++ b/apps/mobile/components/AppHeader/AppHeader.tsx @@ -0,0 +1,99 @@ +import { ArrowLeft, type LucideIcon, MoreVertical } from "lucide-react-native"; +import { View, type ViewProps } from "react-native"; +import { IconButton } from "@/components/IconButton"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type AppHeaderProps = ViewProps & { + title: string; + subtitle?: string; + /** Show the leading back button. Default true. */ + showBack?: boolean; + onBack?: () => void; + backAccessibilityLabel?: string; + /** Show the trailing actions button. Default true. */ + showActions?: boolean; + onActions?: () => void; + actionsAccessibilityLabel?: string; + /** Override the trailing actions icon (default MoreVertical). */ + actionsIcon?: LucideIcon; + /** Adds a layered shadow for visual separation from scrolling content. */ + isScrolled?: boolean; +}; + +/** + * Top navigation header on every chat view (UC-RENDER-01 §A, UC-SESS-04 §A). + * + * Per mol-app-header spec: + * - Three-region flex: leading back (optional) + centered title/subtitle + trailing actions (optional) + * - Subtitle (project · branch) appears below the title in --md type-meta + * - `isScrolled` adds a 1px bottom shadow for layered separation + * + * Composes first-party IconButton + Text. + */ +export function AppHeader({ + title, + subtitle, + showBack = true, + onBack, + backAccessibilityLabel = "Back to sessions", + showActions = true, + onActions, + actionsAccessibilityLabel = "Session actions", + actionsIcon = MoreVertical, + isScrolled = false, + className, + ...props +}: AppHeaderProps) { + return ( + + {showBack ? ( + + ) : ( + + )} + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {showActions ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/components/AppHeader/index.ts b/apps/mobile/components/AppHeader/index.ts new file mode 100644 index 00000000000..edfdab9cc64 --- /dev/null +++ b/apps/mobile/components/AppHeader/index.ts @@ -0,0 +1 @@ +export { AppHeader, type AppHeaderProps } from "./AppHeader"; diff --git a/apps/mobile/components/ApprovalFooter/ApprovalFooter.stories.tsx b/apps/mobile/components/ApprovalFooter/ApprovalFooter.stories.tsx new file mode 100644 index 00000000000..392ebd4207b --- /dev/null +++ b/apps/mobile/components/ApprovalFooter/ApprovalFooter.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { View } from "react-native"; +import { ApprovalFooter } from "./ApprovalFooter"; + +const meta: Meta = { + title: "Molecules/ApprovalFooter", + component: ApprovalFooter, + parameters: { + docs: { + description: { + component: + "Sticky footer above composer during a tool-approval pause (UC-PAUSE-01 §A). Top amber ToolStatusRule + optional queue Badge + 3 Buttons (Decline / Approve / Always). Order is intentional one-handed UX: Approve in center thumb zone, Decline outer to reduce accidental taps. `resolving` dims row + spinner on indicated button. Composes ToolStatusRule + Badge + Button.", + }, + }, + layout: "fullscreen", + }, + args: { + queueCount: 1, + queueIndex: 1, + disabled: false, + }, + argTypes: { + queueCount: { + control: { type: "number", min: 1, max: 9 }, + description: "Total approvals queued (counter shows when > 1)", + }, + queueIndex: { + control: { type: "number", min: 1, max: 9 }, + description: "Current 1-indexed position in queue", + }, + resolving: { + control: { type: "select" }, + options: ["(none)", "decline", "approve", "always"], + mapping: { + "(none)": undefined, + decline: "decline", + approve: "approve", + always: "always", + }, + description: "Show spinner on indicated button + dim row", + }, + disabled: { control: "boolean" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Single: Story = {}; + +export const Queued: Story = { + args: { queueCount: 4, queueIndex: 1 }, +}; + +export const ResolvingApprove: Story = { + args: { resolving: "approve" }, +}; + +export const ResolvingDecline: Story = { + args: { resolving: "decline" }, +}; + +export const ResolvingAlways: Story = { + args: { resolving: "always" }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; + +export const InContextWithRule: Story = { + render: () => ( + + + + {/* PendingApprovalCard would mount here in real flow */} + + + + + ), +}; diff --git a/apps/mobile/components/ApprovalFooter/ApprovalFooter.tsx b/apps/mobile/components/ApprovalFooter/ApprovalFooter.tsx new file mode 100644 index 00000000000..f3265975d23 --- /dev/null +++ b/apps/mobile/components/ApprovalFooter/ApprovalFooter.tsx @@ -0,0 +1,119 @@ +import { ActivityIndicator, View, type ViewProps } from "react-native"; +import { ToolStatusRule } from "@/components/ToolStatusRule"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type ApprovalFooterResolvingAction = "decline" | "approve" | "always"; + +export type ApprovalFooterProps = ViewProps & { + queueCount?: number; + queueIndex?: number; + onDecline?: () => void; + onApprove?: () => void; + onAlways?: () => void; + /** When set, dims the action row and shows a spinner on the indicated button. */ + resolving?: ApprovalFooterResolvingAction; + /** Full-footer disabled (greyed out, no spinner). */ + disabled?: boolean; +}; + +/** + * Sticky footer that docks above the composer during a tool-approval pause + * (UC-PAUSE-01 §A). Pairs with PendingApprovalCard in the message stream above. + * + * Per mol-approval-footer spec: + * - Top horizontal amber ToolStatusRule connects visually to the pending card + * - Optional queue counter Badge ("1 OF 4") visible when queueCount > 1 + * - Action order: Decline · Approve · Always (intentional one-handed UX deviation + * from the wireframe — center is thumb-reachable for the most-common positive + * action, destructive is outer) + * - `resolving` dims the row + shows a spinner on the indicated button + * + * Composes ToolStatusRule + vendor Badge + vendor Button + ActivityIndicator. + */ +export function ApprovalFooter({ + queueCount = 1, + queueIndex = 1, + onDecline, + onApprove, + onAlways, + resolving, + disabled, + className, + ...props +}: ApprovalFooterProps) { + const showCounter = queueCount > 1; + const isResolving = resolving !== undefined; + + return ( + + + + {showCounter ? ( + + + {queueIndex} OF {queueCount} + + + ) : null} + + + + + + + + ); +} diff --git a/apps/mobile/components/ApprovalFooter/index.ts b/apps/mobile/components/ApprovalFooter/index.ts new file mode 100644 index 00000000000..62b16fca3fb --- /dev/null +++ b/apps/mobile/components/ApprovalFooter/index.ts @@ -0,0 +1,5 @@ +export { + ApprovalFooter, + type ApprovalFooterProps, + type ApprovalFooterResolvingAction, +} from "./ApprovalFooter"; diff --git a/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.stories.tsx b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.stories.tsx new file mode 100644 index 00000000000..3f4e696165e --- /dev/null +++ b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { View } from "react-native"; +import { + AssistantMessageHead, + type AssistantMessageHeadVariant, +} from "./AssistantMessageHead"; + +const VARIANTS: AssistantMessageHeadVariant[] = [ + "idle", + "streaming", + "thinking", + "paused", + "completed", +]; + +const meta: Meta = { + title: "Molecules/AssistantMessageHead", + component: AssistantMessageHead, + parameters: { + docs: { + description: { + component: + "Header row for an assistant message (UC-RENDER-01). Avatar + ASSISTANT label + · + timestamp + optional status segment. 5 variants drive status visibility: idle (none) · streaming · thinking · paused · completed. Composes vendor Avatar + first-party StatusDot + Text.", + }, + }, + layout: "centered", + }, + args: { + initials: "A", + label: "ASSISTANT", + timestamp: "12:43 PM", + variant: "idle", + }, + argTypes: { + initials: { control: "text" }, + label: { control: "text" }, + timestamp: { control: "text" }, + variant: { control: { type: "select" }, options: VARIANTS }, + completedDuration: { control: "text" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Idle: Story = {}; + +export const Streaming: Story = { args: { variant: "streaming" } }; + +export const Thinking: Story = { args: { variant: "thinking" } }; + +export const Paused: Story = { args: { variant: "paused" } }; + +export const Completed: Story = { + args: { variant: "completed", completedDuration: "3.2s" }, +}; + +export const AllVariants: Story = { + render: () => ( + + {VARIANTS.map((v) => ( + + ))} + + ), +}; diff --git a/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.tsx b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.tsx new file mode 100644 index 00000000000..4c83aee7807 --- /dev/null +++ b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.tsx @@ -0,0 +1,106 @@ +import { View, type ViewProps } from "react-native"; +import { StatusDot } from "@/components/StatusDot"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type AssistantMessageHeadVariant = + | "idle" + | "streaming" + | "thinking" + | "paused" + | "completed"; + +type VariantConfig = { + dotVariant?: "live" | "warning" | "success" | "neutral"; + statusText?: string; + dotOpacity?: number; +}; + +const VARIANT: Record = { + idle: {}, + streaming: { dotVariant: "live", statusText: "STREAMING" }, + thinking: { dotVariant: "warning", statusText: "THINKING" }, + paused: { dotVariant: "warning", statusText: "PAUSED" }, + completed: { dotVariant: "success", statusText: "COMPLETED" }, +}; + +export type AssistantMessageHeadProps = ViewProps & { + /** Initial(s) shown in the avatar fallback. Default "A". */ + initials?: string; + /** Label text. Defaults to "ASSISTANT". */ + label?: string; + timestamp: string; + variant?: AssistantMessageHeadVariant; + /** Extra trailing text for completed (e.g. "· 3.2s"). */ + completedDuration?: string; +}; + +/** + * Header row for an assistant message (UC-RENDER-01). + * + * Per mol-assistant-message-head spec: + * - Avatar (sm accent) + ASSISTANT label + · + timestamp + optional status segment + * - 5 variants drive the status segment visibility + content + * - Non-interactive — body organism handles long-press + * + * Composes vendor Avatar + first-party StatusDot + Text. + */ +export function AssistantMessageHead({ + initials = "A", + label = "ASSISTANT", + timestamp, + variant = "idle", + completedDuration, + className, + ...props +}: AssistantMessageHeadProps) { + const cfg = VARIANT[variant]; + const showStatus = variant !== "idle" && cfg.statusText; + + return ( + + + + + {initials} + + + + + + {label} + + · + + {timestamp} + + + {showStatus ? ( + + · + {cfg.dotVariant ? ( + + ) : null} + + {cfg.statusText} + {variant === "completed" && completedDuration + ? ` · ${completedDuration}` + : ""} + + + ) : null} + + ); +} diff --git a/apps/mobile/components/AssistantMessageHead/index.ts b/apps/mobile/components/AssistantMessageHead/index.ts new file mode 100644 index 00000000000..5f3e0916fa5 --- /dev/null +++ b/apps/mobile/components/AssistantMessageHead/index.ts @@ -0,0 +1,5 @@ +export { + AssistantMessageHead, + type AssistantMessageHeadProps, + type AssistantMessageHeadVariant, +} from "./AssistantMessageHead"; diff --git a/apps/mobile/components/Banner/Banner.stories.tsx b/apps/mobile/components/Banner/Banner.stories.tsx new file mode 100644 index 00000000000..69b68a2683f --- /dev/null +++ b/apps/mobile/components/Banner/Banner.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { View } from "react-native"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { Banner } from "./Banner"; + +const meta: Meta = { + title: "Molecules/Banner", + component: Banner, + parameters: { + docs: { + description: { + component: + "Full-width status banner above chat content. 4 variants (offline · unpaid · dispatch-failed · permission-denied) × 2 shapes (inline · stacked). Top horizontal ToolStatusRule accent in variant color. Composes ToolStatusRule + Icon + Text + IconButton.", + }, + }, + layout: "fullscreen", + }, + args: { + variant: "offline", + shape: "inline", + headline: "Host offline · auto-retrying in 3s", + }, + argTypes: { + variant: { + control: { type: "select" }, + options: ["offline", "unpaid", "dispatch-failed", "permission-denied"], + }, + shape: { + control: { type: "select" }, + options: ["inline", "stacked"], + }, + headline: { control: "text" }, + body: { control: "text", description: "Body text — stacked shape only" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Offline: Story = {}; + +export const Unpaid: Story = { + args: { + variant: "unpaid", + headline: "Workspace plan upgrade required", + }, + render: (args) => ( + + Upgrade + + } + /> + ), +}; + +export const DispatchFailed: Story = { + args: { + variant: "dispatch-failed", + headline: "Failed to dispatch — host unreachable", + }, + render: (args) => ( + + Retry + + } + /> + ), +}; + +export const PermissionDeniedStacked: Story = { + args: { + variant: "permission-denied", + shape: "stacked", + headline: "Notifications disabled", + body: "Enable notifications in iOS Settings to receive pause approvals while the app is in the background.", + }, + render: (args) => ( + + Open Settings → + + } + /> + ), +}; + +export const Dismissible: Story = { + args: { + headline: "Host offline · auto-retrying", + onDismiss: () => {}, + }, +}; + +export const AllInlineVariants: Story = { + render: () => ( + + + + + + + ), +}; diff --git a/apps/mobile/components/Banner/Banner.tsx b/apps/mobile/components/Banner/Banner.tsx new file mode 100644 index 00000000000..48735505752 --- /dev/null +++ b/apps/mobile/components/Banner/Banner.tsx @@ -0,0 +1,139 @@ +import { + AlertTriangle, + Bell, + type LucideIcon, + WifiOff, + X, +} from "lucide-react-native"; +import type { ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; +import { IconButton } from "@/components/IconButton"; +import { ToolStatusRule } from "@/components/ToolStatusRule"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type BannerVariant = + | "offline" + | "unpaid" + | "dispatch-failed" + | "permission-denied"; + +type VariantConfig = { + bgClass: string; + textClass: string; + ruleVariant: "pending" | "error"; + defaultIcon: LucideIcon; +}; + +const VARIANT: Record = { + offline: { + bgClass: "bg-state-warning-bg", + textClass: "text-state-warning-fg", + ruleVariant: "pending", + defaultIcon: WifiOff, + }, + unpaid: { + bgClass: "bg-state-danger-bg", + textClass: "text-state-danger-fg", + ruleVariant: "error", + defaultIcon: AlertTriangle, + }, + "dispatch-failed": { + bgClass: "bg-state-danger-bg", + textClass: "text-state-danger-fg", + ruleVariant: "error", + defaultIcon: AlertTriangle, + }, + "permission-denied": { + bgClass: "bg-state-warning-bg", + textClass: "text-state-warning-fg", + ruleVariant: "pending", + defaultIcon: Bell, + }, +}; + +export type BannerProps = ViewProps & { + variant?: BannerVariant; + shape?: "inline" | "stacked"; + headline: string; + body?: string; + icon?: LucideIcon; + cta?: ReactNode; + onDismiss?: () => void; + dismissAccessibilityLabel?: string; +}; + +/** + * Full-width status banner above chat (UC-PLATF-01 + UC-PLATF-03). + * + * Per mol-banner spec: + * - 4 variants: offline · unpaid · dispatch-failed · permission-denied + * - 2 shapes: inline (icon · headline · CTA in one row) · stacked + * (icon+headline row, then body, then CTA below) + * - Top horizontal ToolStatusRule accent in variant color + * - Optional dismiss IconButton via onDismiss + * + * Composes ToolStatusRule + Icon + Text + IconButton. + */ +export function Banner({ + variant = "offline", + shape = "inline", + headline, + body, + icon, + cta, + onDismiss, + dismissAccessibilityLabel = "Dismiss", + className, + ...props +}: BannerProps) { + const cfg = VARIANT[variant]; + const resolvedIcon = icon ?? cfg.defaultIcon; + const isStacked = shape === "stacked"; + + return ( + + + + + + + + {headline} + + + {!isStacked && cta ? {cta} : null} + {onDismiss ? ( + + ) : null} + + {isStacked && body ? ( + + {body} + + ) : null} + {isStacked && cta ? {cta} : null} + + + ); +} diff --git a/apps/mobile/components/Banner/index.ts b/apps/mobile/components/Banner/index.ts new file mode 100644 index 00000000000..e862ece0074 --- /dev/null +++ b/apps/mobile/components/Banner/index.ts @@ -0,0 +1 @@ +export { Banner, type BannerProps, type BannerVariant } from "./Banner"; diff --git a/apps/mobile/components/CodeBlock/CodeBlock.stories.tsx b/apps/mobile/components/CodeBlock/CodeBlock.stories.tsx new file mode 100644 index 00000000000..ecffde44d0a --- /dev/null +++ b/apps/mobile/components/CodeBlock/CodeBlock.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { CodeBlock } from "./CodeBlock"; + +const SAMPLE_TS = `export const billing = router({ + getInvoice: publicProcedure + .input(z.string()) + .query(({ input }) => db.invoice.find(input)), +});`; + +const SAMPLE_BASH = `bun install +bun run typecheck +bun run lint:fix`; + +const SAMPLE_LONG = Array.from({ length: 40 }) + .map((_, i) => `console.log("line ${i + 1} of the long code block");`) + .join("\n"); + +const meta: Meta = { + title: "Molecules/CodeBlock", + component: CodeBlock, + parameters: { + docs: { + description: { + component: + "Fenced code block for assistant messages. Language label (mono uppercase) + Copy IconButton + Separator + monospace body. Copy shows check icon + 'Copied' for 1500ms. Composes IconButton + Separator + Text.", + }, + }, + layout: "fullscreen", + }, + args: { + code: SAMPLE_TS, + language: "typescript", + overflow: false, + bare: false, + }, + argTypes: { + code: { control: "text" }, + language: { control: "text" }, + overflow: { + control: "boolean", + description: "Internal scroll for long code", + }, + bare: { control: "boolean", description: "No border (only sunken bg)" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Typescript: Story = {}; + +export const Bash: Story = { + args: { code: SAMPLE_BASH, language: "bash" }, +}; + +export const NoLanguage: Story = { + args: { code: SAMPLE_BASH, language: undefined }, +}; + +export const Overflow: Story = { + args: { code: SAMPLE_LONG, overflow: true, language: "javascript" }, +}; + +export const Bare: Story = { + args: { bare: true }, +}; diff --git a/apps/mobile/components/CodeBlock/CodeBlock.tsx b/apps/mobile/components/CodeBlock/CodeBlock.tsx new file mode 100644 index 00000000000..163bcf6db11 --- /dev/null +++ b/apps/mobile/components/CodeBlock/CodeBlock.tsx @@ -0,0 +1,88 @@ +import { Check, Copy } from "lucide-react-native"; +import { useState } from "react"; +import { ScrollView, View, type ViewProps } from "react-native"; +import { IconButton } from "@/components/IconButton"; +import { Separator } from "@/components/ui/separator"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type CodeBlockProps = ViewProps & { + code: string; + language?: string; + /** Called when user taps the Copy button. Caller handles clipboard write. */ + onCopy?: (code: string) => void; + /** When true, body is internally scrollable (>320pt content). */ + overflow?: boolean; + /** No border variant (only sunken bg). */ + bare?: boolean; +}; + +/** + * Fenced code block for assistant message stream (UC-RENDER-03 §A). + * + * Per mol-code-block spec: + * - header: language label (mono uppercase muted) + Copy IconButton + * - hairline divider via Separator + * - body: monospace text, optional internal scroll when overflow=true + * - Copy button briefly shows check icon + "Copied" for 1500ms + * + * Composes first-party IconButton + vendor Separator + Text. + */ +export function CodeBlock({ + code, + language, + onCopy, + overflow, + bare, + className, + ...props +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + onCopy?.(code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + + + {language ? ( + + {language} + + ) : ( + + )} + + + + {overflow ? ( + + + {code} + + + ) : ( + + {code} + + )} + + ); +} diff --git a/apps/mobile/components/CodeBlock/index.ts b/apps/mobile/components/CodeBlock/index.ts new file mode 100644 index 00000000000..a7b0c9f0e1a --- /dev/null +++ b/apps/mobile/components/CodeBlock/index.ts @@ -0,0 +1 @@ +export { CodeBlock, type CodeBlockProps } from "./CodeBlock"; diff --git a/apps/mobile/components/CollapsedBlock/CollapsedBlock.stories.tsx b/apps/mobile/components/CollapsedBlock/CollapsedBlock.stories.tsx new file mode 100644 index 00000000000..259c8b251be --- /dev/null +++ b/apps/mobile/components/CollapsedBlock/CollapsedBlock.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { CollapsedBlock } from "./CollapsedBlock"; + +const PLAN_STEPS = [ + "1. Locate the reconnect backoff constant", + "2. Replace 250ms with exponential backoff", + "3. Preserve inner try/catch", + "4. Add unit tests for the backoff schedule", +]; + +const REASONING_TEXT = + "The user is reporting reconnect storms on Wi-Fi flap. The backoff is hardcoded to 250ms which is too aggressive — I should adjust it to be exponential starting at 500ms."; + +const meta: Meta = { + title: "Molecules/CollapsedBlock", + component: CollapsedBlock, + parameters: { + docs: { + description: { + component: + "Collapsible block wrapping agent-generated structured content (UC-RENDER-05/06). 3 kinds: plan (sparkles + accent) · reasoning (brain + muted) · subagent (bot + muted, indented with left rule). Tap summary to toggle. Composes vendor Collapsible + Separator + Icon + Text.", + }, + }, + layout: "fullscreen", + }, + args: { + kind: "plan", + meta: "12 steps · 1m est", + defaultOpen: false, + }, + argTypes: { + kind: { + control: { type: "select" }, + options: ["plan", "reasoning", "subagent"], + }, + meta: { control: "text", description: "Optional meta text after label" }, + defaultOpen: { control: "boolean" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const PlanCollapsed: Story = { + render: (args) => ( + + + {PLAN_STEPS.map((s) => ( + + {s} + + ))} + + + ), +}; + +export const PlanExpanded: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + {PLAN_STEPS.map((s) => ( + + {s} + + ))} + + + ), +}; + +export const Reasoning: Story = { + args: { kind: "reasoning", meta: "2.4s", defaultOpen: true }, + render: (args) => ( + + + {REASONING_TEXT} + + + ), +}; + +export const Subagent: Story = { + args: { + kind: "subagent", + meta: "code-reviewer · 12 tool calls", + defaultOpen: true, + }, + render: (args) => ( + + + + Sub-agent invoked code-reviewer with the staged diff. + + + Verdict: 0 blocking findings, 2 nit-level suggestions. + + + + ), +}; + +export const AllKinds: Story = { + render: () => ( + + + + + + ), +}; diff --git a/apps/mobile/components/CollapsedBlock/CollapsedBlock.tsx b/apps/mobile/components/CollapsedBlock/CollapsedBlock.tsx new file mode 100644 index 00000000000..2d21db662d5 --- /dev/null +++ b/apps/mobile/components/CollapsedBlock/CollapsedBlock.tsx @@ -0,0 +1,112 @@ +import { + Bot, + Brain, + ChevronDown, + type LucideIcon, + Sparkles, +} from "lucide-react-native"; +import { type ReactNode, useState } from "react"; +import { Pressable, View, type ViewProps } from "react-native"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Icon } from "@/components/ui/icon"; +import { Separator } from "@/components/ui/separator"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type CollapsedBlockKind = "plan" | "reasoning" | "subagent"; + +type KindConfig = { + icon: LucideIcon; + label: string; + iconColorClass: string; + indentClass?: string; +}; + +const KIND: Record = { + plan: { icon: Sparkles, label: "PLAN", iconColorClass: "text-primary" }, + reasoning: { + icon: Brain, + label: "REASONING", + iconColorClass: "text-muted-foreground", + }, + subagent: { + icon: Bot, + label: "SUBAGENT", + iconColorClass: "text-muted-foreground", + indentClass: "ml-6 border-l border-muted-foreground/40 pl-3", + }, +}; + +export type CollapsedBlockProps = ViewProps & { + kind?: CollapsedBlockKind; + meta?: string; + defaultOpen?: boolean; + children?: ReactNode; +}; + +/** + * Collapsible block wrapping agent-generated structured content (UC-RENDER-05/06). + * + * Per mol-collapsed-block spec, 3 kinds: + * - plan — sparkles + PLAN + accent icon, agent's proposed step list + * - reasoning — brain + REASONING + muted icon, extended thinking trace + * - subagent — bot + SUBAGENT + muted, indented with left accent rule + * + * Tap on the summary toggles expand/collapse via vendor Collapsible primitives. + * Chevron rotates 180° when open. + * + * Composes vendor Collapsible + Separator + Icon + Text. + */ +export function CollapsedBlock({ + kind = "plan", + meta, + defaultOpen = false, + children, + className, + ...props +}: CollapsedBlockProps) { + const cfg = KIND[kind]; + const [open, setOpen] = useState(defaultOpen); + + return ( + + + + + + + {cfg.label} + + {meta ? ( + + · {meta} + + ) : ( + + )} + + + + + + + + + {children} + + + + + ); +} diff --git a/apps/mobile/components/CollapsedBlock/index.ts b/apps/mobile/components/CollapsedBlock/index.ts new file mode 100644 index 00000000000..4712707afd6 --- /dev/null +++ b/apps/mobile/components/CollapsedBlock/index.ts @@ -0,0 +1,5 @@ +export { + CollapsedBlock, + type CollapsedBlockKind, + type CollapsedBlockProps, +} from "./CollapsedBlock"; diff --git a/apps/mobile/components/ComposerRow/ComposerRow.stories.tsx b/apps/mobile/components/ComposerRow/ComposerRow.stories.tsx new file mode 100644 index 00000000000..1fa655ce67e --- /dev/null +++ b/apps/mobile/components/ComposerRow/ComposerRow.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { View } from "react-native"; +import { ComposerRow, type ComposerRowVariant } from "./ComposerRow"; + +const VARIANTS: ComposerRowVariant[] = [ + "idle", + "typing", + "streaming", + "sending", +]; + +const meta: Meta = { + title: "Molecules/ComposerRow", + component: ComposerRow, + parameters: { + docs: { + description: { + component: + "Composer cluster — single rounded container with Textarea on top and an action toolbar inside the same chrome below. Toolbar order mirrors the Claude iOS reference: LEFT [+ commands] [⚙ settings pill] · spacer · RIGHT [send / stop / dots]. 4 state variants drive the right-slot swap and editability. Composes vendor Textarea + first-party IconButton + ComposerSettingsButton + ProgressDots.", + }, + }, + layout: "fullscreen", + }, + args: { + variant: "idle", + placeholder: "Type a message…", + settings: { + modelName: "Sonnet 4.6", + permissionMode: "default", + thinkingLevel: "off", + }, + onCommandsPress: () => {}, + }, + argTypes: { + variant: { + control: { type: "select" }, + options: VARIANTS, + description: + "idle (Send disabled) · typing (Send active) · streaming (Stop) · sending (ProgressDots)", + }, + value: { + control: "text", + description: "Controlled textarea value", + }, + placeholder: { control: "text" }, + commandsAccessibilityLabel: { control: "text" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Idle: Story = {}; + +export const Typing: Story = { + args: { + variant: "typing", + value: "Refactor the relay tunnel reconnect loop", + }, +}; + +export const Streaming: Story = { + args: { variant: "streaming" }, +}; + +export const Sending: Story = { + args: { variant: "sending", value: "Sending this message…" }, +}; + +export const ThinkingOnPermissionAcceptEdits: Story = { + args: { + variant: "typing", + value: "About to refactor", + settings: { + modelName: "Opus 4.7", + permissionMode: "acceptEdits", + thinkingLevel: "medium", + }, + }, +}; + +export const NoCommandsButton: Story = { + args: { onCommandsPress: undefined }, +}; + +export const NoSettingsButton: Story = { + args: { settings: undefined }, +}; + +export const Minimal: Story = { + args: { settings: undefined, onCommandsPress: undefined }, + parameters: { + docs: { + description: { + story: + "Composer with no leading toolbar buttons — just textarea + send. Useful for embedded contexts where settings/commands live elsewhere.", + }, + }, + }, +}; + +export const AllStates: Story = { + render: () => ( + + {VARIANTS.map((v) => ( + {}} + /> + ))} + + ), +}; diff --git a/apps/mobile/components/ComposerRow/ComposerRow.tsx b/apps/mobile/components/ComposerRow/ComposerRow.tsx new file mode 100644 index 00000000000..b9a9501fa0f --- /dev/null +++ b/apps/mobile/components/ComposerRow/ComposerRow.tsx @@ -0,0 +1,146 @@ +import { Send, Slash, Square } from "lucide-react-native"; +import { View, type ViewProps } from "react-native"; +import { + ComposerSettingsButton, + type PermissionMode, + type ThinkingLevel, +} from "@/components/ComposerSettingsButton"; +import { IconButton } from "@/components/IconButton"; +import { ProgressDots } from "@/components/ProgressDots"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +export type ComposerRowVariant = "idle" | "typing" | "streaming" | "sending"; + +export type ComposerRowProps = ViewProps & { + variant?: ComposerRowVariant; + value?: string; + onChangeText?: (text: string) => void; + onSend?: () => void; + onStop?: () => void; + placeholder?: string; + /** Composer settings (model + permission + thinking) — pass undefined to hide the settings button. */ + settings?: { + modelName: string; + permissionMode?: PermissionMode; + thinkingLevel?: ThinkingLevel; + isOpen?: boolean; + onPress?: () => void; + }; + /** Tap handler for the leading commands (+) button. When omitted, the button is hidden. */ + onCommandsPress?: () => void; + commandsAccessibilityLabel?: string; +}; + +/** + * Composer cluster — Claude iOS layout. Single rounded container with the + * textarea on top and an action toolbar inside the same chrome below. + * + * Toolbar order (mirrors Claude iOS reference): + * - LEFT: [+] commands button → [Shield/Model/Brain] settings pill + * - RIGHT: send / stop / progress-dots (state-driven swap) + * + * Variants (textarea + right slot): + * - idle — empty input, Send disabled + * - typing — populated input, Send active (primary ember) + * - streaming — input non-editable, Stop replaces Send (destructive) + * - sending — input non-editable, ProgressDots replaces Send in 44pt slot + * + * Composes vendor Textarea + first-party IconButton + ComposerSettingsButton + + * ProgressDots. The slash-command popover the `+` button opens is the host's + * concern (organism, deferred to Wave 3). + */ +export function ComposerRow({ + variant = "idle", + value, + onChangeText, + onSend, + onStop, + placeholder = "Type a message…", + settings, + onCommandsPress, + commandsAccessibilityLabel = "Open commands", + className, + ...props +}: ComposerRowProps) { + const isDisabled = variant === "streaming" || variant === "sending"; + + return ( + + +