diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 98ad209745..be5d9bf1e4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -280,7 +280,11 @@ repos:
- id: mobile-format
name: mobile format
- entry: bash -c 'cd mobile && bun install --frozen-lockfile && bunx prettier --write "${@#mobile/}"' _
+ # pass_filenames: false so this runs as a single invocation (like the
+ # mobile lint/typecheck hooks above). Passing filenames makes pre-commit
+ # split them into multiple batches that each re-run `bun install`, which
+ # race on bun's cache (EEXIST locally / FileNotFound on CI).
+ entry: bash -c 'cd mobile && bun install --frozen-lockfile && bun run format'
language: system
- files: ^mobile/
- types_or: [javascript, jsx, ts, tsx, json, css]
+ pass_filenames: false
+ files: ^mobile/.*\.(ts|tsx|js|jsx|json|css)$
diff --git a/mobile/bun.lock b/mobile/bun.lock
index 27bcea1a2d..b4a6da38a1 100644
--- a/mobile/bun.lock
+++ b/mobile/bun.lock
@@ -7,10 +7,15 @@
"dependencies": {
"@onyx-ai/shared": "file:../web/lib/shared",
"@react-native-community/netinfo": "12.0.1",
+ "@rn-primitives/portal": "^1.4.0",
+ "@rn-primitives/separator": "^1.4.0",
+ "@rn-primitives/slot": "^1.4.0",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.101.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-persist-client": "^5.101.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"expo": "~56.0.9",
"expo-build-properties": "~56.0.17",
"expo-constants": "~56.0.17",
@@ -31,8 +36,10 @@
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
+ "react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
+ "tailwind-merge": "^3.6.0",
"zustand": "^5.0.14",
},
"devDependencies": {
@@ -408,6 +415,8 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg=="],
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA=="],
+
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA=="],
@@ -454,6 +463,14 @@
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.85.3", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.3" }, "optionalPeers": ["@types/react"] }, "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig=="],
+ "@rn-primitives/portal": ["@rn-primitives/portal@1.4.0", "", { "dependencies": { "zustand": "^5.0.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-5e3v7arUuJh0yDsvK7HRKqqGOg4U2U8TIn1OU6sYPaBkQ0aoXu+ZWR6mhcXPvEBOlgDmpM++bz3unW7f4twxgA=="],
+
+ "@rn-primitives/separator": ["@rn-primitives/separator@1.4.0", "", { "dependencies": { "@radix-ui/react-separator": "^1.1.8", "@rn-primitives/slot": "1.4.0", "@rn-primitives/types": "1.4.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-Wv6miGxrqf/yYWIUDbk1l7NHU8ZCYJJkygQ8LbD6AwiVOiMJHF8q+Dfuq/Eoe3T09a+e/ctb1E4dFKfN/K/btw=="],
+
+ "@rn-primitives/slot": ["@rn-primitives/slot@1.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-p8qIYH44HgXDbsxPFIbRS/IrKWOqb2/JDT8ivFAfhL8QSW0gtVZZo8I/wFip1vV9vRl8D3zMHUBB/PrzrN0Qww=="],
+
+ "@rn-primitives/types": ["@rn-primitives/types@1.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-U7El2BbYXZG8WZrOIV4y1wpxH8aJA/sKH3SL2tZTL153ENj8aOpZ9QwyUoAU2t+sKVPDejrKjo89HeNuIuwPGQ=="],
+
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@shopify/flash-list": ["@shopify/flash-list@2.0.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w=="],
@@ -660,6 +677,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
+
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
"bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
@@ -704,6 +723,8 @@
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
"cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
@@ -714,6 +735,8 @@
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -746,6 +769,12 @@
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
+ "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
+
+ "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
+
+ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
+
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -792,6 +821,14 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+ "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
+
+ "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
+
+ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
+
+ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -804,6 +841,8 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
@@ -1242,6 +1281,8 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+ "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
+
"memfs": ["memfs@4.57.7", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.7", "@jsonjoy.com/fs-fsa": "4.57.7", "@jsonjoy.com/fs-node": "4.57.7", "@jsonjoy.com/fs-node-builtins": "4.57.7", "@jsonjoy.com/fs-node-to-fsa": "4.57.7", "@jsonjoy.com/fs-node-utils": "4.57.7", "@jsonjoy.com/fs-print": "4.57.7", "@jsonjoy.com/fs-snapshot": "4.57.7", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-YZPphUQZSRGk6ddPlsNuMbztrLwsbUATFNZcqKscSbSJZ4g0+Y3vSZLJ/rfnGZaB1FFhC7SrywZXev6i8lnHgg=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
@@ -1334,6 +1375,8 @@
"npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
+ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
+
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
"ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="],
@@ -1490,6 +1533,8 @@
"react-native-screens": ["react-native-screens@4.25.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.82.0" } }, "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg=="],
+ "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
+
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
"react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
@@ -1666,6 +1711,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+ "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
+
"tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
@@ -1904,6 +1951,8 @@
"connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
+ "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
+
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-import-resolver-node/resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="],
diff --git a/mobile/components.json b/mobile/components.json
new file mode 100644
index 0000000000..001dba3abf
--- /dev/null
+++ b/mobile/components.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://reactnativereusables.com/schema.json",
+ "style": "default",
+ "platform": "nativewind",
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/global.css",
+ "baseColor": "neutral",
+ "cssVariables": true
+ },
+ "tsx": true,
+ "aliases": {
+ "components": "@/components",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "utils": "@/lib/utils",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/mobile/package.json b/mobile/package.json
index f9485478de..8a318758e8 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -5,10 +5,15 @@
"dependencies": {
"@onyx-ai/shared": "file:../web/lib/shared",
"@react-native-community/netinfo": "12.0.1",
+ "@rn-primitives/portal": "^1.4.0",
+ "@rn-primitives/separator": "^1.4.0",
+ "@rn-primitives/slot": "^1.4.0",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.101.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-persist-client": "^5.101.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"expo": "~56.0.9",
"expo-build-properties": "~56.0.17",
"expo-constants": "~56.0.17",
@@ -29,8 +34,10 @@
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
+ "react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
+ "tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
diff --git a/mobile/src/app/_layout.tsx b/mobile/src/app/_layout.tsx
index 3ef8440009..66d467e8d0 100644
--- a/mobile/src/app/_layout.tsx
+++ b/mobile/src/app/_layout.tsx
@@ -11,10 +11,12 @@ import { StatusBar } from "expo-status-bar";
import * as SplashScreen from "expo-splash-screen";
import { vars } from "nativewind";
import { varsLight, varsDark } from "@onyx-ai/shared/native";
+import { PortalHost } from "@rn-primitives/portal";
import { persister, persistMaxAge, queryClient } from "@/query/client";
import { bindAppStateFocus } from "@/query/focus";
import { bindOnlineManager } from "@/query/online";
+import { SidebarProvider } from "@/components/sidebar";
// Show the native Onyx splash until the first frame is ready, then reveal the app.
SplashScreen.preventAutoHideAsync();
@@ -56,8 +58,15 @@ export default function RootLayout() {
client={queryClient}
persistOptions={{ persister, maxAge: persistMaxAge }}
>
-
-
+ {/* SidebarProvider owns the shared open/closed (folded) state so any screen
+ can open the sidebar and the portalled overlay can read it. PortalHost is
+ the last child of the themed root, so the overlay renders above all screens
+ while still inheriting the vars() theme + safe-area insets. */}
+
+
+
+
+
diff --git a/mobile/src/app/index.tsx b/mobile/src/app/index.tsx
index 00f05cdd70..68c7e74573 100644
--- a/mobile/src/app/index.tsx
+++ b/mobile/src/app/index.tsx
@@ -1,25 +1,129 @@
-import { Text, View } from "react-native";
+import { useState } from "react";
+import { Pressable, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
-// Home screen — styled with @onyx-ai/shared design tokens. The semantic classes
-// (bg-background-neutral-*, text-text-*, rounded-12) resolve through the vars()
-// provider in _layout.tsx, so colors flip with the system light/dark scheme exactly
-// like web. Spacing/radius come from the shared token scale too.
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { SidebarLayouts, SidebarTab, useSidebar } from "@/components/sidebar";
+import SvgSidebar from "@/icons/sidebar";
+import SvgBubbleText from "@/icons/bubble-text";
+import SvgSearch from "@/icons/search";
+import SvgFolder from "@/icons/folder";
+import SvgPlus from "@/icons/plus";
+import SvgSettings from "@/icons/settings";
+
+// Demo of the React Native Sidebar — a port of the web Opal `SidebarLayouts` shell.
+// Tap the menu icon to slide the sidebar in over the screen; tap the backdrop or
+// swipe left to close. Colors resolve from @onyx-ai/shared, identical to web in
+// both light and dark.
+
+function SidebarTrigger() {
+ const { setFolded } = useSidebar();
+ return (
+ setFolded(false)}
+ hitSlop={8}
+ className="rounded-08 p-2 active:bg-background-tint-03"
+ >
+
+
+ );
+}
+
export default function Home() {
+ const { setFolded } = useSidebar();
+ const [selected, setSelected] = useState("general");
+
+ function choose(key: string) {
+ setSelected(key);
+ setFolded(true); // auto-dismiss after selection (demo behavior)
+ }
+
return (
-
- Onyx Mobile
-
- Design tokens via @onyx-ai/shared — colors, spacing & radius, light &
- dark.
-
-
-
- rounded-12 · background-neutral-02
-
-
+
+
+ Onyx Mobile
+
+
+
+ Tap the menu icon to open the sidebar. Tap the backdrop or swipe left
+ to close.
+
+
+
+
+ (
+
+ Onyx
+
+ )}
+ />
+
+
+
+ choose("general")}
+ >
+ General
+
+ choose("search")}
+ >
+ Search
+
+
+
+
+
+
+ }
+ >
+ choose("onboarding")}
+ rightChildren={
+
+ 3
+
+ }
+ >
+ Onboarding
+
+ choose("spec")}
+ >
+ Spec draft
+
+
+
+
+
+ choose("settings")}
+ >
+ Settings
+
+
+
);
}
diff --git a/mobile/src/components/sidebar/Sidebar.tsx b/mobile/src/components/sidebar/Sidebar.tsx
new file mode 100644
index 0000000000..bf9f24a109
--- /dev/null
+++ b/mobile/src/components/sidebar/Sidebar.tsx
@@ -0,0 +1,244 @@
+import * as React from "react";
+import { Pressable, ScrollView, View } from "react-native";
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Portal } from "@rn-primitives/portal";
+
+import { cn } from "@/lib/utils";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import SvgSidebar from "@/icons/sidebar";
+import { useSidebar } from "@/components/sidebar/SidebarProvider";
+import {
+ SIDEBAR_ANIM_MS,
+ SIDEBAR_WIDTH_EXPANDED,
+} from "@/components/sidebar/interfaces";
+
+// ---------------------------------------------------------------------------
+// SidebarLayouts — the RN sidebar shell (Root/Header/Body/Footer/Section). A
+// full-height overlay that slides in from the left with a tinted backdrop:
+// • portalled above all screens via @rn-primitives/portal,
+// • reanimated translateX slide + backdrop opacity off the `folded` state,
+// • gesture-handler Pan for swipe-to-close (gated to horizontal so it doesn't
+// steal the Body ScrollView's vertical scroll),
+// • colors + typography from the shared design tokens.
+// ---------------------------------------------------------------------------
+
+interface SidebarRootProps {
+ /** Kept for web API parity; the mobile overlay behaves the same regardless. */
+ foldable?: boolean;
+ children: React.ReactNode;
+}
+
+function SidebarRoot({ children }: SidebarRootProps) {
+ const { folded, setFolded } = useSidebar();
+ const insets = useSafeAreaInsets();
+
+ // `progress` is derived (read-only) from the React `folded` state: 1 = open,
+ // 0 = closed (off-screen). Driving it via useDerivedValue keeps the open/close
+ // animation purely state-driven — no manual shared-value mutation in an effect.
+ const progress = useDerivedValue(
+ () => withTiming(folded ? 0 : 1, { duration: SIDEBAR_ANIM_MS }),
+ [folded],
+ );
+
+ // `drag` is a live swipe offset, mutated ONLY inside the Pan worklet and read by
+ // the animated styles — it layers on top of `progress` so a finger drag tracks 1:1.
+ const drag = useSharedValue(0);
+
+ const columnStyle = useAnimatedStyle(() => {
+ const base = (progress.value - 1) * SIDEBAR_WIDTH_EXPANDED;
+ return { transform: [{ translateX: Math.min(0, base + drag.value) }] };
+ });
+
+ const backdropStyle = useAnimatedStyle(() => {
+ const dragFade = 1 + drag.value / SIDEBAR_WIDTH_EXPANDED;
+ return { opacity: progress.value * Math.max(0, Math.min(1, dragFade)) };
+ });
+
+ const close = React.useCallback(() => setFolded(true), [setFolded]);
+
+ // Swipe left on the column to close. `activeOffsetX` gates activation to
+ // horizontal movement, letting vertical scroll fall through to the Body. Built
+ // inline (not via a hook) so `drag` isn't treated as a frozen hook argument —
+ // useSharedValue refs are stable, so re-creating the gesture object is cheap and
+ // the Root only re-renders when `folded` flips.
+ const pan = Gesture.Pan()
+ .activeOffsetX([-15, 15])
+ .onUpdate((e) => {
+ drag.value = Math.min(0, e.translationX); // leftward only
+ })
+ .onEnd((e) => {
+ const shouldClose =
+ e.translationX < -SIDEBAR_WIDTH_EXPANDED / 3 || e.velocityX < -500;
+ drag.value = withTiming(0, { duration: SIDEBAR_ANIM_MS });
+ if (shouldClose) runOnJS(setFolded)(true);
+ });
+
+ return (
+
+
+ {/* Tinted backdrop (web `bg-mask-03`; the ~1px backdrop-blur is dropped — not
+ a mobile token). Tap to close. */}
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Header — topbar (logo + close button) with optional pinned content below
+// ---------------------------------------------------------------------------
+
+interface SidebarHeaderProps {
+ logo?: (folded: boolean | undefined) => React.ReactNode;
+ /** Kept for web API parity (no hover on touch). */
+ showLogoWhenFolded?: boolean;
+ children?: React.ReactNode;
+}
+
+function SidebarHeader({ logo, children }: SidebarHeaderProps) {
+ const { folded, setFolded } = useSidebar();
+ const logoEl = logo != null ? logo(folded) : null;
+
+ if (logoEl == null && children == null) return null;
+
+ return (
+
+ {logoEl != null && (
+
+ {logoEl}
+ setFolded(true)}
+ hitSlop={8}
+ className="rounded-08 p-1 active:bg-background-tint-03"
+ >
+
+
+
+ )}
+ {children != null && {children}}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Body — scrollable content area
+// ---------------------------------------------------------------------------
+
+interface SidebarBodyProps {
+ /** Accepted for web API parity; scroll-offset persistence is out of scope for the shell. */
+ scrollKey?: string;
+ children?: React.ReactNode;
+}
+
+function SidebarBody({ children }: SidebarBodyProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Footer — pinned content below the scroll area
+// ---------------------------------------------------------------------------
+
+interface SidebarFooterProps {
+ children?: React.ReactNode;
+}
+
+function SidebarFooter({ children }: SidebarFooterProps) {
+ return {children};
+}
+
+// ---------------------------------------------------------------------------
+// Section — titled group within the scrollable body
+// ---------------------------------------------------------------------------
+
+interface SidebarSectionProps {
+ title?: string;
+ /** Optional action (e.g. a "+" button); always visible on touch (no hover). */
+ action?: React.ReactNode;
+ /** Dims the section header to indicate it is unavailable. */
+ disabled?: boolean;
+ children?: React.ReactNode;
+}
+
+function SidebarSection({
+ title,
+ action,
+ disabled,
+ children,
+}: SidebarSectionProps) {
+ return (
+
+ {title ? (
+
+
+
+ {title}
+
+
+ {action != null && {action}}
+
+ ) : (
+
+ )}
+ {children}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Exports — mirror the web `SidebarLayouts` namespace
+// ---------------------------------------------------------------------------
+
+export const SidebarLayouts = {
+ Root: SidebarRoot,
+ Header: SidebarHeader,
+ Body: SidebarBody,
+ Footer: SidebarFooter,
+ Section: SidebarSection,
+};
+
+export type {
+ SidebarRootProps,
+ SidebarHeaderProps,
+ SidebarBodyProps,
+ SidebarFooterProps,
+ SidebarSectionProps,
+};
diff --git a/mobile/src/components/sidebar/SidebarProvider.tsx b/mobile/src/components/sidebar/SidebarProvider.tsx
new file mode 100644
index 0000000000..05d0ff65d7
--- /dev/null
+++ b/mobile/src/components/sidebar/SidebarProvider.tsx
@@ -0,0 +1,54 @@
+import {
+ createContext,
+ useContext,
+ useState,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+
+// ---------------------------------------------------------------------------
+// Sidebar state. `folded` is the single source of truth: on a phone the sidebar
+// is an overlay, so `folded === true` means "closed / off-screen" and
+// `folded === false` means "open". Default closed; opened from a trigger.
+//
+// `setFolded` is React's own `useState` setter, so it already accepts either a
+// value (`setFolded(false)`) or an updater (`setFolded((prev) => !prev)` for a
+// toggle) — no wrapper needed.
+// ---------------------------------------------------------------------------
+
+export interface SidebarStateContextType {
+ folded: boolean;
+ setFolded: Dispatch>;
+}
+
+const SidebarStateContext = createContext(
+ undefined,
+);
+
+export interface SidebarProviderProps {
+ /** Initial fold state. Defaults to `true` (closed) on mobile. */
+ defaultFolded?: boolean;
+ children: ReactNode;
+}
+
+export function SidebarProvider({
+ defaultFolded = true,
+ children,
+}: SidebarProviderProps) {
+ const [folded, setFolded] = useState(defaultFolded);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSidebar(): SidebarStateContextType {
+ const context = useContext(SidebarStateContext);
+ if (context === undefined) {
+ throw new Error("useSidebar must be used within a SidebarProvider");
+ }
+ return context;
+}
diff --git a/mobile/src/components/sidebar/SidebarTab.tsx b/mobile/src/components/sidebar/SidebarTab.tsx
new file mode 100644
index 0000000000..33c5857ea7
--- /dev/null
+++ b/mobile/src/components/sidebar/SidebarTab.tsx
@@ -0,0 +1,148 @@
+import * as React from "react";
+import { Pressable, View } from "react-native";
+import { router, type Href } from "expo-router";
+
+import { cn } from "@/lib/utils";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import type { IconFunctionComponent } from "@/icons/types";
+import type { SidebarVariant } from "@/components/sidebar/interfaces";
+
+// ---------------------------------------------------------------------------
+// SidebarTab — React Native port of web's Opal SidebarTab
+// (web/lib/opal/src/components/buttons/sidebar-tab/components.tsx), built on the
+// `Interactive.Stateful` sidebar-heavy / sidebar-light color matrix
+// (web/lib/opal/src/core/interactive/stateful/styles.css:543-662).
+//
+// Web "hover" → RN "pressed" (NativeWind's `active:` modifier on Pressable).
+// All classes resolve to the same Onyx token hex as web.
+// ---------------------------------------------------------------------------
+
+interface SidebarTabColors {
+ /** Selected background; empty/filled is transparent (omitted). */
+ bg: string;
+ /** Label foreground color class. */
+ label: string;
+ /** Icon foreground. */
+ icon: string;
+}
+
+function resolveColors(
+ variant: SidebarVariant,
+ selected: boolean,
+ disabled: boolean,
+): SidebarTabColors {
+ if (disabled) {
+ return { bg: "", label: "text-text-03", icon: "text-text-03" };
+ }
+ if (variant === "sidebar-light") {
+ // All states use text-02 foreground; selected adds the tint-00 background.
+ return {
+ bg: selected ? "bg-background-tint-00" : "",
+ label: "text-text-02",
+ icon: "text-text-02",
+ };
+ }
+ // sidebar-heavy
+ if (selected) {
+ return {
+ bg: "bg-background-tint-00",
+ label: "text-text-04",
+ icon: "text-text-03",
+ };
+ }
+ return { bg: "", label: "text-text-03", icon: "text-text-02" };
+}
+
+interface SidebarTabProps {
+ /** Collapses the label, showing only the icon (inert on phone — sidebar is open/closed). */
+ folded?: boolean;
+ /** Marks this tab as the currently active item. */
+ selected?: boolean;
+ /** Color variant. @default "sidebar-heavy" */
+ variant?: SidebarVariant;
+ /** Renders an empty spacer in place of the icon, for nested items. */
+ nested?: boolean;
+ /** Disables the tab — muted colors, suppressed press. */
+ disabled?: boolean;
+ onPress?: () => void;
+ /** Optional route to navigate to on press (expo-router). */
+ href?: Href;
+ icon?: IconFunctionComponent;
+ /** Content rendered on the right (e.g. a count or action). */
+ rightChildren?: React.ReactNode;
+ /** Accepted for web API parity; a no-op on touch. */
+ tooltip?: string;
+ children?: React.ReactNode;
+}
+
+function SidebarTab({
+ folded,
+ selected = false,
+ variant = "sidebar-heavy",
+ nested,
+ disabled = false,
+ onPress,
+ href,
+ icon,
+ rightChildren,
+ children,
+}: SidebarTabProps) {
+ const colors = resolveColors(variant, selected, disabled);
+
+ function handlePress() {
+ // `disabled` Pressable already blocks onPress, so no guard needed here.
+ onPress?.();
+ if (href != null) router.navigate(href);
+ }
+
+ return (
+
+ {/* Leading slot — mirrors web's ContentSm (web/lib/opal/.../content/ContentSm.tsx
+ + styles.css): a 16px icon inside a `p-0.5` container (the padding "around"
+ the icon), then a 2px gap (web's `gap: 0.125rem`) before the label, or a
+ matching spacer for nested items so labels align. Explicit margin instead of
+ `gap`, which doesn't render reliably in RN/NativeWind. */}
+ {nested ? (
+
+ ) : icon ? (
+
+
+
+ ) : null}
+
+ {!folded && (
+ <>
+ {typeof children === "string" ? (
+
+ {children}
+
+ ) : (
+ {children}
+ )}
+
+ {rightChildren && (
+
+ {rightChildren}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export { SidebarTab, type SidebarTabProps };
diff --git a/mobile/src/components/sidebar/index.ts b/mobile/src/components/sidebar/index.ts
new file mode 100644
index 0000000000..61262803f7
--- /dev/null
+++ b/mobile/src/components/sidebar/index.ts
@@ -0,0 +1,19 @@
+export { SidebarLayouts } from "@/components/sidebar/Sidebar";
+export type {
+ SidebarRootProps,
+ SidebarHeaderProps,
+ SidebarBodyProps,
+ SidebarFooterProps,
+ SidebarSectionProps,
+} from "@/components/sidebar/Sidebar";
+export {
+ SidebarTab,
+ type SidebarTabProps,
+} from "@/components/sidebar/SidebarTab";
+export {
+ SidebarProvider,
+ useSidebar,
+ type SidebarProviderProps,
+ type SidebarStateContextType,
+} from "@/components/sidebar/SidebarProvider";
+export type { SidebarVariant } from "@/components/sidebar/interfaces";
diff --git a/mobile/src/components/sidebar/interfaces.ts b/mobile/src/components/sidebar/interfaces.ts
new file mode 100644
index 0000000000..e77bb04b83
--- /dev/null
+++ b/mobile/src/components/sidebar/interfaces.ts
@@ -0,0 +1,12 @@
+// Shared (non-prop) types for the Sidebar primitive. Mirrors the web Opal
+// `Interactive.Stateful` sidebar variants.
+
+export type SidebarVariant = "sidebar-heavy" | "sidebar-light";
+
+// Sidebar column width, ported from web/lib/opal/src/styles/sizes.css
+// (--sidebar-width-expanded: 15rem). On a phone the folded rail is unused
+// (folded === off-screen), so only the expanded width applies.
+export const SIDEBAR_WIDTH_EXPANDED = 240;
+
+// Slide/backdrop transition duration — matches web's `duration-200`.
+export const SIDEBAR_ANIM_MS = 200;
diff --git a/mobile/src/components/ui/icon.tsx b/mobile/src/components/ui/icon.tsx
new file mode 100644
index 0000000000..1d1d57f0cd
--- /dev/null
+++ b/mobile/src/components/ui/icon.tsx
@@ -0,0 +1,45 @@
+// Why this exists: React Native has no CSS engine, so a react-native-svg icon can't
+// read a Tailwind class like `text-text-02` (on web the browser does this for free).
+// This wrapper is that missing translator — it turns a color class into a real color
+// and hands it to the icon, so icons use the same design tokens as everything else.
+//
+// How: `cssInterop` compiles the className and maps the resolved text color → the
+// icon's `color` prop, which the SVG's `stroke="currentColor"` reads. Adds a default
+// size (16) + color (`text-text-03`).
+import { cssInterop } from "nativewind";
+
+import { cn } from "@/lib/utils";
+import type { IconFunctionComponent, IconProps } from "@/icons/types";
+
+type IconWrapperProps = IconProps & {
+ as: IconFunctionComponent;
+};
+
+function IconImpl({ as: IconComponent, ...props }: IconWrapperProps) {
+ return ;
+}
+
+cssInterop(IconImpl, {
+ className: {
+ target: "style",
+ nativeStyleToProp: { color: true },
+ },
+});
+
+/**
+ * @example
+ * import SvgSearch from "@/icons/search";
+ *
+ */
+function Icon({ as, className, size = 16, ...props }: IconWrapperProps) {
+ return (
+
+ );
+}
+
+export { Icon };
diff --git a/mobile/src/components/ui/separator.tsx b/mobile/src/components/ui/separator.tsx
new file mode 100644
index 0000000000..c407ed92a2
--- /dev/null
+++ b/mobile/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+// react-native-reusables `separator` primitive, vendored and reconciled to Onyx tokens
+// (RNR default `bg-border` → `bg-border-01`).
+import { cn } from "@/lib/utils";
+import * as SeparatorPrimitive from "@rn-primitives/separator";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/mobile/src/components/ui/text.tsx b/mobile/src/components/ui/text.tsx
new file mode 100644
index 0000000000..0431e62cbd
--- /dev/null
+++ b/mobile/src/components/ui/text.tsx
@@ -0,0 +1,52 @@
+// Text — the React Native counterpart of web's Opal `Text`. Typography comes from
+// the shared design-system presets (`@onyx-ai/shared` `textPresets`), selected by a
+// `font` prop with the exact same names as web (`TextFont`): "main-ui-body",
+// "secondary-body", "heading-h3", … Each preset carries family + size + weight +
+// line-height + letter-spacing, so we never hand-pick `font-semibold`/`text-base`.
+// Color stays as a token className (e.g. `text-text-02`), identical to web.
+//
+// Built on the RNR `text` skeleton (Slot + `asChild`).
+//
+// FOLLOW-UP PR — this does NOT yet fully match web's Opal `Text`. Out of scope here;
+// to be reconciled in a dedicated PR:
+// • Add a typed `color?: TextColor` prop (web has it). Color is an untyped
+// `className` today, so token typos aren't caught.
+// • Canonicalize `TextFont` + `TextColor` in `@onyx-ai/shared` as the single source
+// and have both web (Opal) and mobile import them (today `TextFont` is generated
+// for mobile only; `TextColor` lives hand-written in Opal).
+// • Optionally expose `font-*` className utilities (web has `@utility font-heading-h1`)
+// via a NativeWind plugin, for className parity with web.
+import { textPresets, type TextFont } from "@onyx-ai/shared/native";
+import { Slot } from "@rn-primitives/slot";
+import * as React from "react";
+import { Text as RNText, type TextStyle } from "react-native";
+
+import { cn } from "@/lib/utils";
+
+interface TextProps extends React.ComponentProps {
+ /** Typography preset from the shared design system (same names as web's Opal Text). */
+ font?: TextFont;
+ asChild?: boolean;
+}
+
+function Text({
+ className,
+ font = "main-ui-body",
+ asChild = false,
+ style,
+ ...props
+}: TextProps) {
+ const Component = asChild ? Slot : RNText;
+ // `as TextStyle`: presets type fontWeight as `string` (RN narrows it to a union);
+ // the resolved values ("400"/"500"/…) are valid TextStyle at runtime.
+ const preset = textPresets[font] as TextStyle;
+ return (
+
+ );
+}
+
+export { Text, type TextFont };
diff --git a/mobile/src/icons/bubble-text.tsx b/mobile/src/icons/bubble-text.tsx
new file mode 100644
index 0000000000..314c1cf761
--- /dev/null
+++ b/mobile/src/icons/bubble-text.tsx
@@ -0,0 +1,24 @@
+import Svg, { Path } from "react-native-svg";
+
+import type { IconProps } from "@/icons/types";
+
+// RN variant of web Opal `SvgBubbleText` (web/lib/opal/src/icons/bubble-text.tsx).
+const SvgBubbleText = ({ size = 16, ...props }: IconProps) => (
+
+);
+
+export default SvgBubbleText;
diff --git a/mobile/src/icons/folder.tsx b/mobile/src/icons/folder.tsx
new file mode 100644
index 0000000000..75df61c929
--- /dev/null
+++ b/mobile/src/icons/folder.tsx
@@ -0,0 +1,24 @@
+import Svg, { Path } from "react-native-svg";
+
+import type { IconProps } from "@/icons/types";
+
+// RN variant of web Opal `SvgFolder` (web/lib/opal/src/icons/folder.tsx).
+const SvgFolder = ({ size = 16, ...props }: IconProps) => (
+
+);
+
+export default SvgFolder;
diff --git a/mobile/src/icons/plus.tsx b/mobile/src/icons/plus.tsx
new file mode 100644
index 0000000000..4fdd4ecd88
--- /dev/null
+++ b/mobile/src/icons/plus.tsx
@@ -0,0 +1,24 @@
+import Svg, { Path } from "react-native-svg";
+
+import type { IconProps } from "@/icons/types";
+
+// RN variant of web Opal `SvgPlus` (web/lib/opal/src/icons/plus.tsx).
+const SvgPlus = ({ size = 16, ...props }: IconProps) => (
+
+);
+
+export default SvgPlus;
diff --git a/mobile/src/icons/search.tsx b/mobile/src/icons/search.tsx
new file mode 100644
index 0000000000..04d0b72ce0
--- /dev/null
+++ b/mobile/src/icons/search.tsx
@@ -0,0 +1,24 @@
+import Svg, { Path } from "react-native-svg";
+
+import type { IconProps } from "@/icons/types";
+
+// RN variant of web Opal `SvgSearch` (web/lib/opal/src/icons/search.tsx).
+const SvgSearch = ({ size = 16, ...props }: IconProps) => (
+
+);
+
+export default SvgSearch;
diff --git a/mobile/src/icons/settings.tsx b/mobile/src/icons/settings.tsx
new file mode 100644
index 0000000000..51b4b60aec
--- /dev/null
+++ b/mobile/src/icons/settings.tsx
@@ -0,0 +1,32 @@
+import Svg, { Path } from "react-native-svg";
+
+import type { IconProps } from "@/icons/types";
+
+// RN variant of web Opal `SvgSettings` (web/lib/opal/src/icons/settings.tsx). The
+// web source wraps the paths in a clipPath bounded to the full 16x16 viewBox (a
+// visual no-op), so it's omitted here.
+const SvgSettings = ({ size = 16, ...props }: IconProps) => (
+
+);
+
+export default SvgSettings;
diff --git a/mobile/src/icons/sidebar.tsx b/mobile/src/icons/sidebar.tsx
new file mode 100644
index 0000000000..2fa9c1f9fb
--- /dev/null
+++ b/mobile/src/icons/sidebar.tsx
@@ -0,0 +1,24 @@
+import Svg, { Path } from "react-native-svg";
+
+import type { IconProps } from "@/icons/types";
+
+// RN variant of web Opal `SvgSidebar` (web/lib/opal/src/icons/sidebar.tsx).
+const SvgSidebar = ({ size = 16, ...props }: IconProps) => (
+
+);
+
+export default SvgSidebar;
diff --git a/mobile/src/icons/types.ts b/mobile/src/icons/types.ts
new file mode 100644
index 0000000000..fc5eb1e26c
--- /dev/null
+++ b/mobile/src/icons/types.ts
@@ -0,0 +1,14 @@
+import type * as React from "react";
+import type { SvgProps } from "react-native-svg";
+
+// React Native counterpart of web Opal's icon types (web/lib/opal/src/types.ts).
+// Same shape, retargeted to react-native-svg. Icons render with
+// `stroke="currentColor"`, so color flows from the `color` prop — the `Icon`
+// wrapper (components/ui/icon.tsx) sets it from a `text-*` token class.
+export interface IconProps extends SvgProps {
+ className?: string;
+ size?: number;
+ title?: string;
+}
+
+export type IconFunctionComponent = React.FunctionComponent;
diff --git a/mobile/src/lib/utils.ts b/mobile/src/lib/utils.ts
new file mode 100644
index 0000000000..eb4becbcc8
--- /dev/null
+++ b/mobile/src/lib/utils.ts
@@ -0,0 +1,9 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+// Single class-merge helper for every UI component (mirrors web's @/lib/utils `cn`).
+// clsx flattens conditionals/arrays; twMerge resolves conflicting Tailwind utilities
+// so a later class (e.g. an explicit `text-text-02`) wins over a base default.
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/web/lib/shared/style-dictionary.config.mjs b/web/lib/shared/style-dictionary.config.mjs
index 546ca9311e..f6192b41b9 100644
--- a/web/lib/shared/style-dictionary.config.mjs
+++ b/web/lib/shared/style-dictionary.config.mjs
@@ -199,18 +199,36 @@ StyleDictionary.registerFormat({
// generates that JS, not its declarations, so they're spelled out here.
StyleDictionary.registerFormat({
name: "dts/native-vars",
- format: () =>
- "// Generated by Style Dictionary. Do not edit by hand.\n" +
- "export declare const varsLight: Record;\n" +
- "export declare const varsDark: Record;\n" +
- "export interface TextPreset {\n" +
- " fontFamily: string;\n" +
- " fontSize: number;\n" +
- " fontWeight: string;\n" +
- " lineHeight: number;\n" +
- " letterSpacing: number;\n" +
- "}\n" +
- "export declare const textPresets: Record;\n",
+ format: ({ dictionary }) => {
+ // TextFont = the union of preset names (unique path[1] of typography tokens,
+ // path[0] === "text"), so the type ships WITH the tokens and mobile's Text
+ // component can accept `font="main-ui-body"` exactly like web's Opal Text.
+ const fontNames = [
+ ...new Set(
+ dictionary.allTokens
+ .filter((t) => t.path[0] === "text")
+ .map((t) => t.path[1])
+ ),
+ ];
+ const textFontUnion =
+ fontNames.length > 0
+ ? fontNames.map((n) => `"${n}"`).join(" | ")
+ : "never";
+ return (
+ "// Generated by Style Dictionary. Do not edit by hand.\n" +
+ "export declare const varsLight: Record;\n" +
+ "export declare const varsDark: Record;\n" +
+ "export interface TextPreset {\n" +
+ " fontFamily: string;\n" +
+ " fontSize: number;\n" +
+ " fontWeight: string;\n" +
+ " lineHeight: number;\n" +
+ " letterSpacing: number;\n" +
+ "}\n" +
+ `export type TextFont = ${textFontUnion};\n` +
+ "export declare const textPresets: Record;\n"
+ );
+ },
});
// FORMAT -> native.js (mobile, ESM). The RN analog of web's `.dark`: two fully