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..5836ab0eafa --- /dev/null +++ b/apps/mobile/.rnstorybook/main.js @@ -0,0 +1,39 @@ +/** @type {import('@storybook/react-native').StorybookConfig} */ +const main = { + stories: [ + "./stories/**/*.stories.?(ts|tsx|js|jsx)", + "../components/**/*.stories.?(ts|tsx|js|jsx)", + // Wave 4 — chat-view view stories (pixel-perfect Phase 6 COMPOSE). + // Narrow glob points only at `screens/chat-view/` because nothing in + // that subtree imports expo-router or `useTheme`. The broader + // `../screens/**` glob remains disabled — adding it would re-trigger + // the prep-time `UnhandledLinkingContext` crash described below. + "../screens/chat-view/**/*.stories.?(ts|tsx|js|jsx)", + // Wave 5 — sessions-list view stories. Same narrow-glob constraint as + // chat-view (above): nothing in this subtree imports expo-router or + // `useTheme`, so prep-time `loadStory` is safe. + "../screens/sessions-list/**/*.stories.?(ts|tsx|js|jsx)", + // "../screens/**/*.stories.?(ts|tsx|js|jsx)", + // ^ Disabled 2026-05-22. Screen placeholder stories transitively import + // `useTheme` → `lib/theme.ts` → `expo-router/react-navigation`. Storybook 9 + // RN's `loadStory` (called during `createPreparedStoryMapping`) evaluates + // each story module eagerly BEFORE decorators apply, and ends up calling + // expo-router's `useLinking` family which crashes accessing the default + // `UnhandledLinkingContext` value outside a ``. + // + // Wrapping decorators in `` from + // `expo-router/react-navigation` (kept in preview.tsx) does NOT help here + // because Storybook's prep-time render happens outside the decorator chain. + // + // To restore screen stories: refactor them to avoid `useTheme` / decouple + // from `lib/theme.ts` (mirror the pattern used in components/ScrollFade), + // or use `expo-router/testing-library`'s `renderRouter` helper inside a + // custom story `render` function. + ], + 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/AppliedFilterTag/AppliedFilterTag.stories.tsx b/apps/mobile/components/AppliedFilterTag/AppliedFilterTag.stories.tsx new file mode 100644 index 00000000000..ab025cd4fec --- /dev/null +++ b/apps/mobile/components/AppliedFilterTag/AppliedFilterTag.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { ScrollView, View } from "react-native"; +import { AppliedFilterTag } from "./AppliedFilterTag"; + +const meta: Meta = { + title: "Molecules/Sessions/AppliedFilterTag", + component: AppliedFilterTag, + parameters: { + docs: { + description: { + component: + "Dismissible filter chip (UC-NAV-08 §C). Workspace variant = git-branch icon + `branch · host`; status variant = colored status icon + label. Body and ✕ are separate tap targets.", + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + kind: "workspace", + label: "main · desktop", + onPress: () => {}, + onDismiss: () => {}, + }, + argTypes: { + kind: { + control: { type: "select" }, + options: ["workspace", "status"], + }, + label: { control: "text" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Workspace: Story = {}; + +export const StatusStreaming: Story = { + args: { kind: "status", label: "Streaming" }, +}; + +export const HorizontalScrollRow: Story = { + render: () => ( + + + + + + + ), +}; diff --git a/apps/mobile/components/AppliedFilterTag/AppliedFilterTag.tsx b/apps/mobile/components/AppliedFilterTag/AppliedFilterTag.tsx new file mode 100644 index 00000000000..75f812efc91 --- /dev/null +++ b/apps/mobile/components/AppliedFilterTag/AppliedFilterTag.tsx @@ -0,0 +1,87 @@ +import { Crosshair, GitBranch, type LucideIcon, X } from "lucide-react-native"; +import { Pressable, type PressableProps, View } from "react-native"; +import { IconButton } from "@/components/IconButton"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type AppliedFilterTagKind = "workspace" | "status"; + +const KIND_ICON: Record = { + workspace: GitBranch, + status: Crosshair, +}; + +export type AppliedFilterTagProps = Omit & { + kind: AppliedFilterTagKind; + label: string; + icon?: LucideIcon; + onDismiss?: () => void; + dismissAccessibilityLabel?: string; +}; + +/** + * Dismissible chip representing one applied filter (UC-NAV-08 §C). + * + * Two kinds: + * - workspace: git-branch icon + `branch · host` label + * - status: status icon (defaults to Crosshair) + status name + * + * Renders a separate dismiss button so the tap targets are unambiguous: + * - Tap chip body → focus/scroll-into-view (optional caller behavior) + * - Tap ✕ → remove this filter only + * + * Composes IconButton for the dismiss. Used inside a horizontal-scroll row + * that the host (ProjectChipHeader belowSearch slot) lays out. + */ +export function AppliedFilterTag({ + kind, + label, + icon, + onDismiss, + onPress, + disabled, + dismissAccessibilityLabel, + className, + ...props +}: AppliedFilterTagProps) { + const LeadingIcon = icon ?? KIND_ICON[kind]; + + return ( + + + + {label} + + + + ); +} diff --git a/apps/mobile/components/AppliedFilterTag/index.ts b/apps/mobile/components/AppliedFilterTag/index.ts new file mode 100644 index 00000000000..6d798e66f26 --- /dev/null +++ b/apps/mobile/components/AppliedFilterTag/index.ts @@ -0,0 +1,5 @@ +export { + AppliedFilterTag, + type AppliedFilterTagKind, + type AppliedFilterTagProps, +} from "./AppliedFilterTag"; 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..f6f56610ffa --- /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-amber-50 dark:bg-amber-950/30", + textClass: "text-amber-600", + ruleVariant: "pending", + defaultIcon: WifiOff, + }, + unpaid: { + bgClass: "bg-destructive/10", + textClass: "text-destructive", + ruleVariant: "error", + defaultIcon: AlertTriangle, + }, + "dispatch-failed": { + bgClass: "bg-destructive/10", + textClass: "text-destructive", + ruleVariant: "error", + defaultIcon: AlertTriangle, + }, + "permission-denied": { + bgClass: "bg-amber-50 dark:bg-amber-950/30", + textClass: "text-amber-600", + 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/BottomSheet/BottomSheet.stories.tsx b/apps/mobile/components/BottomSheet/BottomSheet.stories.tsx new file mode 100644 index 00000000000..b782525618a --- /dev/null +++ b/apps/mobile/components/BottomSheet/BottomSheet.stories.tsx @@ -0,0 +1,120 @@ +import { + BottomSheetModalProvider, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import type { Meta, StoryObj } from "@storybook/react-native"; +import { useRef } from "react"; +import { View } from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { + BottomSheet, + type BottomSheetProps, + type BottomSheetRef, +} from "./BottomSheet"; + +const SAMPLE_LINES: ReadonlyArray = [ + "Approve · Decline · Always", + "Workspace: superset · main", + "Last touched 4 minutes ago", + "Tap to switch sessions", + "Long press for context menu", + "Permission mode: default", + "Thinking level: medium", + "Model: Sonnet 4.6", + "3 pending tool calls", + "Press ESC to dismiss", + "Drag down to close", + "Backdrop tap also closes", +]; + +type ShowcaseProps = Omit & { + triggerLabel: string; + bodyLines: number; +}; + +function BottomSheetShowcase({ + triggerLabel, + bodyLines, + ...rest +}: ShowcaseProps) { + const sheetRef = useRef(null); + const lines = SAMPLE_LINES.slice(0, Math.min(bodyLines, SAMPLE_LINES.length)); + return ( + + + + + + + + Sheet content + + {lines.map((line) => ( + + {line} + + ))} + + + + + + + ); +} + +const meta: Meta = { + title: "Organisms/BottomSheet", + component: BottomSheetShowcase, + parameters: { + docs: { + description: { + component: + "Project-themed wrapper around @gorhom/bottom-sheet BottomSheetModal. Imperative API via `useRef` — call `ref.current?.present()` to open, `.dismiss()` to close. Drag-down dismiss + tap-backdrop dismiss + themed handle/surface. Used by ask_user sheet (UC-PAUSE-02), session overflow (UC-SESS-04), new-chat picker (UC-NAV-04), project/filter pickers (UC-NAV-08).", + }, + }, + layout: "fullscreen", + }, + args: { + triggerLabel: "Open sheet", + bodyLines: 4, + snapPoints: ["50%"], + enablePanDownToClose: true, + enableBackdropDismiss: true, + }, + argTypes: { + triggerLabel: { control: "text" }, + bodyLines: { control: { type: "number", min: 1, max: 12, step: 1 } }, + enablePanDownToClose: { control: "boolean" }, + enableBackdropDismiss: { control: "boolean" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const HalfHeight: Story = {}; + +export const TallSheet: Story = { + args: { + snapPoints: ["80%"], + bodyLines: 10, + }, +}; + +export const TwoSnapPoints: Story = { + args: { + snapPoints: ["30%", "75%"], + bodyLines: 8, + }, +}; diff --git a/apps/mobile/components/BottomSheet/BottomSheet.tsx b/apps/mobile/components/BottomSheet/BottomSheet.tsx new file mode 100644 index 00000000000..f27dac6878e --- /dev/null +++ b/apps/mobile/components/BottomSheet/BottomSheet.tsx @@ -0,0 +1,105 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetHandle, + type BottomSheetHandleProps, + BottomSheetModal, + type BottomSheetModalProps, +} from "@gorhom/bottom-sheet"; +import { forwardRef, useCallback, useMemo } from "react"; +import { useColorScheme } from "react-native"; + +// biome-ignore lint/suspicious/noExplicitAny: gorhom BottomSheetModal is generic over T = any for data, matches their public ref type +export type BottomSheetRef = BottomSheetModal; + +export type BottomSheetProps = Omit< + BottomSheetModalProps, + "snapPoints" | "ref" +> & { + /** Snap points as percentages ("50%") or fixed pixels (300). Default: ["50%"]. */ + snapPoints?: ReadonlyArray; + /** Default true — drag down to dismiss. */ + enablePanDownToClose?: boolean; + /** Default true — tap backdrop to dismiss. */ + enableBackdropDismiss?: boolean; +}; + +/** + * Project-themed wrapper around @gorhom/bottom-sheet `BottomSheetModal`. + * + * Imperative API per gorhom docs: + * const ref = useRef(null); + * + *