updates
This commit is contained in:
parent
e7e16478e3
commit
6b8bcee7ea
1
apps/expo-github-mobile/.gitignore
vendored
1
apps/expo-github-mobile/.gitignore
vendored
@ -31,6 +31,7 @@ yarn-error.*
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
|
||||
121
apps/expo-github-mobile/package-lock.json
generated
121
apps/expo-github-mobile/package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
},
|
||||
@ -3905,6 +3906,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001766",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
||||
@ -4254,6 +4264,26 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"css-color-keywords": "^1.0.0",
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@ -4408,6 +4438,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
|
||||
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/env-editor": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
||||
@ -6361,6 +6397,15 @@
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
|
||||
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
@ -6427,12 +6472,34 @@
|
||||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
|
||||
"integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"entities": "~2.0.0",
|
||||
"linkify-it": "^2.0.0",
|
||||
"mdurl": "^1.0.1",
|
||||
"uc.micro": "^1.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.js"
|
||||
}
|
||||
},
|
||||
"node_modules/marky": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
|
||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
|
||||
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
@ -7348,6 +7415,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
@ -7426,6 +7499,23 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@ -7588,6 +7678,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-fit-image": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
||||
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
|
||||
"license": "Beerware",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-is-edge-to-edge": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
||||
@ -7598,6 +7697,22 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-markdown-display": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
|
||||
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-to-react-native": "^3.0.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-native-fit-image": "^1.5.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.2.0",
|
||||
"react-native": ">=0.50.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
||||
@ -8706,6 +8821,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
|
||||
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
},
|
||||
|
||||
89
apps/expo-github-mobile/src/components/CodeBlock.tsx
Normal file
89
apps/expo-github-mobile/src/components/CodeBlock.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
|
||||
interface Props {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<Props> = ({ code, language = 'text' }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
// Format language for display
|
||||
const displayLanguage = language || 'text'
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header with language and copy button */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.language}>{displayLanguage}</Text>
|
||||
<TouchableOpacity style={styles.copyButton} onPress={handleCopy}>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark' : 'copy-outline'}
|
||||
size={14}
|
||||
color={Colors.secondaryText}
|
||||
/>
|
||||
<Text style={styles.copyText}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Code content */}
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.codeText}>{code}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: Colors.surfaceBackground,
|
||||
borderRadius: Radius.MD,
|
||||
overflow: 'hidden',
|
||||
marginVertical: Spacing.SM,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.chipBorder,
|
||||
},
|
||||
language: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: Colors.secondaryText,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.XS,
|
||||
paddingHorizontal: Spacing.SM,
|
||||
paddingVertical: Spacing.XS,
|
||||
},
|
||||
copyText: {
|
||||
fontSize: 12,
|
||||
color: Colors.secondaryText,
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: 'Menlo',
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
color: Colors.primaryText,
|
||||
padding: Spacing.MD,
|
||||
},
|
||||
})
|
||||
|
||||
export default CodeBlock
|
||||
296
apps/expo-github-mobile/src/components/MarkdownView.tsx
Normal file
296
apps/expo-github-mobile/src/components/MarkdownView.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native'
|
||||
import Markdown, { RenderRules } from 'react-native-markdown-display'
|
||||
import CodeBlock from './CodeBlock'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
}
|
||||
|
||||
const MarkdownView: React.FC<Props> = ({ content }) => {
|
||||
// Function to extract language and code from fenced code block
|
||||
const parseCodeBlock = (content: string): { language: string; code: string } => {
|
||||
const lines = content.split('\n')
|
||||
if (lines.length === 0) return { language: 'text', code: content }
|
||||
|
||||
const firstLine = lines[0].trim()
|
||||
// Check if first line looks like a language identifier
|
||||
if (firstLine.length > 0 && firstLine.length < 20 && /^[a-zA-Z0-9+#+-]*$/.test(firstLine)) {
|
||||
return {
|
||||
language: firstLine,
|
||||
code: lines.slice(1).join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
return { language: 'text', code: content }
|
||||
}
|
||||
|
||||
// Custom render rules
|
||||
const rules: RenderRules = {
|
||||
// Code blocks (fenced)
|
||||
fence: (node, children, parent, styles) => {
|
||||
const { language, code } = parseCodeBlock(node.content)
|
||||
return <CodeBlock key={node.key} code={code} language={language} />
|
||||
},
|
||||
// Inline code
|
||||
codeinline: (node, children, parent, styles) => {
|
||||
return (
|
||||
<Text key={node.key} style={markdownStyles.codeinline}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
// Headings
|
||||
heading1: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.heading1}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
heading2: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.heading2}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
heading3: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.heading3}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
heading4: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.heading4}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
heading5: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.heading5}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
heading6: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.heading6}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
// Lists
|
||||
bullet_list: (node, children, parent, styles) => (
|
||||
<View key={node.key} style={markdownStyles.bulletList}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
ordered_list: (node, children, parent, styles) => (
|
||||
<View key={node.key} style={markdownStyles.bulletList}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
list_item: (node, children, parent, styles) => (
|
||||
<View key={node.key} style={markdownStyles.listItem}>
|
||||
<Text style={markdownStyles.bullet}>{'\u2022 '}</Text>
|
||||
<View style={markdownStyles.listItemContent}>{children}</View>
|
||||
</View>
|
||||
),
|
||||
// Blockquote
|
||||
blockquote: (node, children, parent, styles) => (
|
||||
<View key={node.key} style={markdownStyles.blockquote}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
// Links
|
||||
link: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.link}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
// Bold
|
||||
strong: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.strong}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
// Italic
|
||||
italic: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.italic}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
// Strikethrough
|
||||
s: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.s}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
// Horizontal rule
|
||||
hr: (node, children, parent, styles) => <View key={node.key} style={markdownStyles.hr} />,
|
||||
// Paragraph
|
||||
paragraph: (node, children, parent, styles) => (
|
||||
<Text key={node.key} style={markdownStyles.paragraph}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
// Hard break
|
||||
hardbreak: (node, children, parent, styles) => <Text key={node.key}>{"\n"}</Text>,
|
||||
// Soft break
|
||||
softbreak: (node, children, parent, styles) => <Text key={node.key}>{"\n"}</Text>,
|
||||
// Table
|
||||
table: (node, children, parent, styles) => (
|
||||
<ScrollView horizontal key={node.key} style={markdownStyles.tableContainer}>
|
||||
<View>{children}</View>
|
||||
</ScrollView>
|
||||
),
|
||||
tbody: (node, children, parent, styles) => (
|
||||
<View key={node.key}>{children}</View>
|
||||
),
|
||||
thead: (node, children, parent, styles) => (
|
||||
<View key={node.key}>{children}</View>
|
||||
),
|
||||
tr: (node, children, parent, styles) => (
|
||||
<View key={node.key} style={markdownStyles.tableRow}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
th: (node, children, parent, styles) => (
|
||||
<View key={node.key} style={markdownStyles.tableCellHeader}>
|
||||
<Text style={markdownStyles.tableCellText}>{children}</Text>
|
||||
</View>
|
||||
),
|
||||
td: (node, children, parent, styles) => (
|
||||
<View key={node.key} style={markdownStyles.tableCell}>
|
||||
<Text style={markdownStyles.tableCellText}>{children}</Text>
|
||||
</View>
|
||||
),
|
||||
}
|
||||
|
||||
const markdownStyles = StyleSheet.create({
|
||||
body: {
|
||||
color: Colors.primaryText,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: Spacing.MD,
|
||||
lineHeight: 24,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: Colors.primaryText,
|
||||
marginTop: Spacing.LG,
|
||||
marginBottom: Spacing.SM,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: Colors.primaryText,
|
||||
marginTop: Spacing.MD,
|
||||
marginBottom: Spacing.SM,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.primaryText,
|
||||
marginTop: Spacing.SM,
|
||||
marginBottom: Spacing.XS,
|
||||
},
|
||||
heading4: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.primaryText,
|
||||
marginTop: Spacing.SM,
|
||||
marginBottom: Spacing.XS,
|
||||
},
|
||||
heading5: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: Colors.primaryText,
|
||||
marginTop: Spacing.SM,
|
||||
marginBottom: Spacing.XS,
|
||||
},
|
||||
heading6: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: Colors.primaryText,
|
||||
marginTop: Spacing.SM,
|
||||
marginBottom: Spacing.XS,
|
||||
},
|
||||
codeinline: {
|
||||
backgroundColor: Colors.inputBackground,
|
||||
color: Colors.accentLight,
|
||||
fontFamily: 'Menlo',
|
||||
fontSize: 13,
|
||||
paddingHorizontal: Spacing.SM,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
bulletList: {
|
||||
marginVertical: Spacing.SM,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: Spacing.XS,
|
||||
},
|
||||
bullet: {
|
||||
color: Colors.primaryText,
|
||||
marginRight: Spacing.SM,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: Colors.accent,
|
||||
paddingLeft: Spacing.MD,
|
||||
marginVertical: Spacing.SM,
|
||||
opacity: 0.85,
|
||||
},
|
||||
link: {
|
||||
color: Colors.accent,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
strong: {
|
||||
fontWeight: '700',
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
s: {
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
hr: {
|
||||
height: 1,
|
||||
backgroundColor: Colors.chipBorder,
|
||||
marginVertical: Spacing.MD,
|
||||
},
|
||||
tableContainer: {
|
||||
marginVertical: Spacing.SM,
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.chipBorder,
|
||||
},
|
||||
tableCell: {
|
||||
padding: Spacing.SM,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: Colors.chipBorder,
|
||||
},
|
||||
tableCellHeader: {
|
||||
padding: Spacing.SM,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: Colors.chipBorder,
|
||||
backgroundColor: Colors.inputBackground,
|
||||
},
|
||||
tableCellText: {
|
||||
fontSize: 14,
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Markdown style={markdownStyles} rules={rules} mergeStyle={false}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownView
|
||||
@ -1,8 +1,9 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Colors, Spacing } from '../theme/colors'
|
||||
import { Message } from '../models/types'
|
||||
import { Message, MessageRole } from '../models/types'
|
||||
import ToolCallView from './ToolCallView'
|
||||
import MarkdownView from './MarkdownView'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
@ -23,11 +24,21 @@ const MessageView: React.FC<Props> = ({ message }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isUser = message.role === MessageRole.User
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Message content */}
|
||||
{message.content && (
|
||||
<Text style={styles.messageContent}>{message.content}</Text>
|
||||
<View style={isUser ? styles.userMessageWrapper : styles.assistantMessageWrapper}>
|
||||
{isUser ? (
|
||||
<View style={styles.userBubble}>
|
||||
<MarkdownView content={message.content} />
|
||||
</View>
|
||||
) : (
|
||||
<MarkdownView content={message.content} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
@ -47,10 +58,19 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: Spacing.MD,
|
||||
},
|
||||
messageContent: {
|
||||
fontSize: 16,
|
||||
color: Colors.primaryText,
|
||||
paddingHorizontal: Spacing.LG,
|
||||
userMessageWrapper: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
assistantMessageWrapper: {
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: '#3a3a3a',
|
||||
borderRadius: 18,
|
||||
borderBottomRightRadius: 4,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
maxWidth: '80%',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -82,11 +82,12 @@ const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate })
|
||||
if (!messageText.trim() || !selectedRepo) return
|
||||
|
||||
// Convert GitHubRepo to app Repository
|
||||
// Use selectedBranch (user's choice) not default_branch
|
||||
const repository: Repository = {
|
||||
id: String(selectedRepo.id),
|
||||
name: selectedRepo.name,
|
||||
owner: selectedRepo.owner.login,
|
||||
defaultBranch: selectedRepo.default_branch,
|
||||
defaultBranch: selectedBranch || selectedRepo.default_branch,
|
||||
language: selectedRepo.language || undefined,
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
import { ToolCall } from '../models/types'
|
||||
import { ToolCall, ToolCallStatus } from '../models/types'
|
||||
import { getToolColor } from '../theme/types'
|
||||
|
||||
interface Props {
|
||||
@ -13,31 +13,103 @@ interface Props {
|
||||
|
||||
const ToolCallView: React.FC<Props> = ({ toolCall, isExpanded, onToggle }) => {
|
||||
const toolColor = getToolColor(toolCall.type)
|
||||
const isRunning = toolCall.status === ToolCallStatus.Running
|
||||
const isCompleted = toolCall.status === ToolCallStatus.Completed
|
||||
const isFailed = toolCall.status === ToolCallStatus.Failed
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isRunning) {
|
||||
return <ActivityIndicator size={14} color={toolColor} />
|
||||
}
|
||||
if (isFailed) {
|
||||
return <Ionicons name="close-circle" size={16} color={Colors.error} />
|
||||
}
|
||||
if (isCompleted) {
|
||||
return <Ionicons name="checkmark-circle" size={16} color={Colors.success} />
|
||||
}
|
||||
return <Ionicons name="ellipse" size={16} color={Colors.tertiaryText} />
|
||||
}
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isRunning) return 'Running'
|
||||
if (isFailed) return 'Failed'
|
||||
if (isCompleted) return 'Completed'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
// Parse input to extract relevant info
|
||||
const getInputDisplay = () => {
|
||||
// For Write/Edit, show just the file path
|
||||
if (toolCall.type === 'Write' || toolCall.type === 'Edit') {
|
||||
const match = toolCall.input.match(/^[\s\S]{0,100}?/)
|
||||
return match ? match[0].trim() : toolCall.input
|
||||
}
|
||||
// For Bash, show the command
|
||||
if (toolCall.type === 'Bash') {
|
||||
const lines = toolCall.input.split('\n')
|
||||
return lines[0]?.trim() || toolCall.input
|
||||
}
|
||||
return toolCall.input
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.container, isFailed && styles.containerFailed]}>
|
||||
{/* Header */}
|
||||
<TouchableOpacity style={styles.header} onPress={onToggle}>
|
||||
<Text style={[styles.toolType, { color: toolColor }]}>{toolCall.type}</Text>
|
||||
<Text style={styles.toolInput} numberOfLines={1}>
|
||||
{toolCall.input}
|
||||
<TouchableOpacity
|
||||
style={[styles.header, isExpanded && styles.headerExpanded]}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.toolInfo}>
|
||||
{getStatusIcon()}
|
||||
<Text style={[styles.toolType, { color: toolColor }]}>{toolCall.type}</Text>
|
||||
<Text style={styles.statusText}>({getStatusText()})</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.toolInput} numberOfLines={isExpanded ? undefined : 1}>
|
||||
{getInputDisplay()}
|
||||
</Text>
|
||||
|
||||
<Ionicons
|
||||
name={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
size={12}
|
||||
size={16}
|
||||
color={Colors.tertiaryText}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && toolCall.output && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.outputContainer}
|
||||
>
|
||||
<Text style={styles.outputText}>{toolCall.output}</Text>
|
||||
</ScrollView>
|
||||
{/* Expanded input/output */}
|
||||
{isExpanded && (
|
||||
<View style={styles.expandedContent}>
|
||||
{/* Input section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Input</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.inputText}>{toolCall.input}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Output section */}
|
||||
{toolCall.output && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Output</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.outputScroll}
|
||||
>
|
||||
<Text style={styles.outputText}>{toolCall.output}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Running indicator */}
|
||||
{isRunning && !toolCall.output && (
|
||||
<View style={styles.runningIndicator}>
|
||||
<ActivityIndicator size="small" color={toolColor} />
|
||||
<Text style={styles.runningText}>Executing...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
@ -49,6 +121,11 @@ const styles = StyleSheet.create({
|
||||
borderRadius: Radius.MD,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: Spacing.LG,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
containerFailed: {
|
||||
borderColor: `${Colors.error}40`,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
@ -57,24 +134,73 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
},
|
||||
headerExpanded: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.chipBorder,
|
||||
},
|
||||
toolInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.XS,
|
||||
},
|
||||
toolType: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
color: Colors.tertiaryText,
|
||||
},
|
||||
toolInput: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
color: Colors.secondaryText,
|
||||
fontFamily: 'Menlo',
|
||||
},
|
||||
outputContainer: {
|
||||
expandedContent: {
|
||||
backgroundColor: Colors.surfaceBackground,
|
||||
},
|
||||
section: {
|
||||
paddingVertical: Spacing.SM,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: Colors.chipBorder,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: Colors.tertiaryText,
|
||||
textTransform: 'uppercase',
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingBottom: Spacing.XS,
|
||||
},
|
||||
inputText: {
|
||||
fontFamily: 'Menlo',
|
||||
fontSize: 12,
|
||||
color: Colors.primaryText,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingBottom: Spacing.SM,
|
||||
},
|
||||
outputScroll: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
outputText: {
|
||||
fontFamily: 'Menlo',
|
||||
fontSize: 11,
|
||||
color: Colors.secondaryText,
|
||||
padding: Spacing.MD,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingBottom: Spacing.SM,
|
||||
},
|
||||
runningIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: Spacing.SM,
|
||||
paddingVertical: Spacing.MD,
|
||||
},
|
||||
runningText: {
|
||||
fontSize: 12,
|
||||
color: Colors.tertiaryText,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { GatewayClient, type AgentEventPayload } from '../services/gateway'
|
||||
import { GatewayClient, type AgentEventPayload, type AgentFinalResult } from '../services/gateway'
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
export type { AgentFinalResult }
|
||||
|
||||
export type ToolCallEvent = {
|
||||
toolCallId: string
|
||||
name: string
|
||||
@ -41,7 +43,10 @@ export const useGateway = () => {
|
||||
if (settingsLoading) return
|
||||
if (!settings.gatewayUrl) return
|
||||
|
||||
const client = new GatewayClient(settings.gatewayUrl)
|
||||
const url = settings.gatewayUrl.trim()
|
||||
if (!url) return
|
||||
|
||||
const client = new GatewayClient(url, settings.gatewayToken)
|
||||
clientRef.current = client
|
||||
|
||||
setState((s) => ({ ...s, connecting: true, error: null }))
|
||||
@ -49,10 +54,12 @@ export const useGateway = () => {
|
||||
// Handle agent events
|
||||
const unsubscribe = client.onEvent('agent', (payload) => {
|
||||
const evt = payload as AgentEventPayload
|
||||
console.log('[useGateway] Agent event:', evt.stream, JSON.stringify(evt.data).slice(0, 100))
|
||||
|
||||
switch (evt.stream) {
|
||||
case 'tool': {
|
||||
const data = evt.data as ToolCallEvent
|
||||
console.log('[useGateway] Tool event:', data.name, data.phase)
|
||||
for (const handler of toolCallHandlersRef.current) {
|
||||
handler(data)
|
||||
}
|
||||
@ -60,12 +67,14 @@ export const useGateway = () => {
|
||||
}
|
||||
case 'assistant': {
|
||||
const data = evt.data as AssistantEvent
|
||||
console.log('[useGateway] Assistant event, delta length:', data.delta?.length ?? 0)
|
||||
for (const handler of assistantHandlersRef.current) {
|
||||
handler(data)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'lifecycle': {
|
||||
console.log('[useGateway] Lifecycle event:', evt.data)
|
||||
for (const handler of lifecycleHandlersRef.current) {
|
||||
handler(evt.data)
|
||||
}
|
||||
@ -88,11 +97,12 @@ export const useGateway = () => {
|
||||
clientRef.current = null
|
||||
setState({ connected: false, connecting: false, error: null })
|
||||
}
|
||||
}, [settings.gatewayUrl, settingsLoading])
|
||||
}, [settings.gatewayUrl?.trim(), settingsLoading])
|
||||
|
||||
const sendMessage = useCallback(async (
|
||||
message: string,
|
||||
repoContext?: { owner: string; name: string; branch: string }
|
||||
repoContext?: { owner: string; name: string; branch: string },
|
||||
onFinalResult?: (result: AgentFinalResult) => void
|
||||
): Promise<{ status: string; runId: string } | null> => {
|
||||
const client = clientRef.current
|
||||
if (!client?.connected) {
|
||||
@ -101,11 +111,14 @@ export const useGateway = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.startAgent({
|
||||
message,
|
||||
repoContext,
|
||||
idempotencyKey: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
})
|
||||
const result = await client.startAgent(
|
||||
{
|
||||
message,
|
||||
repoContext,
|
||||
idempotencyKey: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
},
|
||||
onFinalResult
|
||||
)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('[useGateway] Failed to send message', e)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const SETTINGS_KEY = '@clawdbot_settings'
|
||||
@ -6,24 +6,32 @@ const SETTINGS_KEY = '@clawdbot_settings'
|
||||
export interface Settings {
|
||||
githubUsername: string
|
||||
gatewayUrl: string
|
||||
gatewayToken?: string
|
||||
}
|
||||
|
||||
// Default settings from environment variables
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
githubUsername: '',
|
||||
gatewayUrl: 'ws://localhost:18789',
|
||||
githubUsername: process.env.EXPO_PUBLIC_GITHUB_USERNAME || '',
|
||||
gatewayUrl: process.env.EXPO_PUBLIC_GATEWAY_URL || 'ws://localhost:18789',
|
||||
gatewayToken: process.env.EXPO_PUBLIC_GATEWAY_TOKEN || '',
|
||||
}
|
||||
|
||||
export const loadSettings = async (): Promise<Settings> => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(SETTINGS_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
return { ...DEFAULT_SETTINGS, ...parsed }
|
||||
const saved = JSON.parse(raw) as Partial<Settings>
|
||||
// Merge with defaults (env vars take precedence for empty saved values)
|
||||
return {
|
||||
githubUsername: saved.githubUsername || DEFAULT_SETTINGS.githubUsername,
|
||||
gatewayUrl: saved.gatewayUrl || DEFAULT_SETTINGS.gatewayUrl,
|
||||
gatewayToken: saved.gatewayToken || DEFAULT_SETTINGS.gatewayToken,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings', e)
|
||||
return DEFAULT_SETTINGS
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
|
||||
export const saveSettings = async (settings: Settings): Promise<void> => {
|
||||
@ -39,17 +47,17 @@ export const useSettings = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings().then((loaded) => {
|
||||
setSettings(loaded)
|
||||
loadSettings().then((s) => {
|
||||
setSettings(s)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateSettings = async (updates: Partial<Settings>) => {
|
||||
const updateSettings = useCallback(async (updates: Partial<Settings>) => {
|
||||
const newSettings = { ...settings, ...updates }
|
||||
setSettings(newSettings)
|
||||
await saveSettings(newSettings)
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
return { settings, updateSettings, loading }
|
||||
}
|
||||
|
||||
@ -26,6 +26,21 @@ export type AgentEventPayload = {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type AgentFinalResult = {
|
||||
runId: string
|
||||
status: 'ok' | 'error'
|
||||
summary?: string
|
||||
error?: string
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
// Protocol version must match gateway (see src/gateway/protocol/schema/protocol-schemas.ts)
|
||||
const PROTOCOL_VERSION = 3
|
||||
|
||||
// Valid client IDs from gateway
|
||||
const CLIENT_ID = 'clawdbot-ios'
|
||||
const CLIENT_MODE = 'webchat'
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null
|
||||
private pending = new Map<string, {
|
||||
@ -33,30 +48,52 @@ export class GatewayClient {
|
||||
reject: (err: Error) => void
|
||||
}>()
|
||||
private eventHandlers = new Map<string, Set<(payload: unknown) => void>>()
|
||||
private agentCallbacks = new Map<string, (result: AgentFinalResult) => void>()
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectDelay = 1000
|
||||
private messageQueue: string[] = []
|
||||
private handshakeComplete = false
|
||||
private authToken?: string
|
||||
|
||||
constructor(private url: string) {}
|
||||
constructor(private url: string, authToken?: string) {
|
||||
this.authToken = authToken
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.handshakeComplete = false
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[Gateway] Connected')
|
||||
// Send queued messages
|
||||
for (const msg of this.messageQueue) {
|
||||
this.ws?.send(msg)
|
||||
}
|
||||
this.messageQueue = []
|
||||
resolve()
|
||||
console.log('[Gateway] WebSocket opened, sending connect handshake...')
|
||||
// Send connect handshake as first message
|
||||
this.sendConnectHandshake()
|
||||
.then(() => {
|
||||
console.log('[Gateway] Handshake complete')
|
||||
this.handshakeComplete = true
|
||||
// Send queued messages
|
||||
for (const msg of this.messageQueue) {
|
||||
this.ws?.send(msg)
|
||||
}
|
||||
this.messageQueue = []
|
||||
this.reconnectDelay = 1000 // Reset on successful connect
|
||||
resolve()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Gateway] Handshake failed', err)
|
||||
reject(err)
|
||||
})
|
||||
}
|
||||
this.ws.onmessage = (event) => this.handleMessage(event.data)
|
||||
|
||||
this.ws.onmessage = (event) => this.handleMessage(event.data as string)
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('[Gateway] Disconnected', event.code, event.reason)
|
||||
this.handshakeComplete = false
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('[Gateway] Error', error)
|
||||
reject(error)
|
||||
@ -67,6 +104,29 @@ export class GatewayClient {
|
||||
})
|
||||
}
|
||||
|
||||
private async sendConnectHandshake(): Promise<void> {
|
||||
const connectParams = {
|
||||
client: {
|
||||
id: CLIENT_ID,
|
||||
displayName: 'Clawdbot Mobile',
|
||||
version: '1.0.0',
|
||||
mode: CLIENT_MODE,
|
||||
platform: 'ios',
|
||||
},
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
...(this.authToken ? { auth: { token: this.authToken } } : {}),
|
||||
}
|
||||
|
||||
const result = await this.sendRaw('connect', connectParams)
|
||||
|
||||
// Check if we got hello-ok
|
||||
const payload = result as { type?: string; protocol?: number }
|
||||
if (payload?.type !== 'hello-ok') {
|
||||
throw new Error('Expected hello-ok response from gateway')
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
@ -76,6 +136,7 @@ export class GatewayClient {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.handshakeComplete = false
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
@ -93,20 +154,43 @@ export class GatewayClient {
|
||||
private handleMessage(data: string) {
|
||||
try {
|
||||
const frame: GatewayFrame = JSON.parse(data)
|
||||
console.log('[Gateway] <<< Received frame:', frame.type, 'event' in frame ? (frame as { event?: string }).event : '')
|
||||
|
||||
if (frame.type === 'res') {
|
||||
const pending = this.pending.get(frame.id)
|
||||
if (pending) {
|
||||
this.pending.delete(frame.id)
|
||||
if (frame.ok) {
|
||||
console.log('[Gateway] Response OK:', JSON.stringify(frame.payload).slice(0, 200))
|
||||
pending.resolve(frame.payload)
|
||||
} else {
|
||||
console.log('[Gateway] Response ERROR:', frame.error?.message)
|
||||
pending.reject(new Error(frame.error?.message || 'Request failed'))
|
||||
}
|
||||
} else {
|
||||
// This is a follow-up response (e.g., agent completion/error)
|
||||
const payload = frame.payload as { runId?: string; status?: string; summary?: string; error?: unknown } | undefined
|
||||
console.log('[Gateway] Follow-up response:', frame.ok ? 'OK' : 'ERROR', JSON.stringify(payload).slice(0, 300))
|
||||
|
||||
if (payload?.runId) {
|
||||
const callback = this.agentCallbacks.get(payload.runId)
|
||||
if (callback) {
|
||||
this.agentCallbacks.delete(payload.runId)
|
||||
callback({
|
||||
runId: payload.runId,
|
||||
status: payload.status === 'error' || !frame.ok ? 'error' : 'ok',
|
||||
summary: payload.summary,
|
||||
error: frame.error?.message || (payload.error ? String(payload.error) : undefined),
|
||||
result: payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (frame.type === 'event') {
|
||||
console.log('[Gateway] Event received:', frame.event, JSON.stringify(frame.payload).slice(0, 200))
|
||||
const handlers = this.eventHandlers.get(frame.event)
|
||||
if (handlers) {
|
||||
console.log('[Gateway] Found', handlers.size, 'handlers for event:', frame.event)
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(frame.payload)
|
||||
@ -114,6 +198,8 @@ export class GatewayClient {
|
||||
console.error('[Gateway] Event handler error', e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[Gateway] No handlers for event:', frame.event)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -121,6 +207,24 @@ export class GatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Send without waiting for handshake (used for connect handshake itself)
|
||||
private sendRaw(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const frame = { type: 'req', method, params, id }
|
||||
const message = JSON.stringify(frame)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(message)
|
||||
} else {
|
||||
reject(new Error('WebSocket not open'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Send after handshake complete
|
||||
private send(frame: Record<string, unknown>): Promise<unknown> {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const message = JSON.stringify({ ...frame, id })
|
||||
@ -128,7 +232,7 @@ export class GatewayClient {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN && this.handshakeComplete) {
|
||||
this.ws.send(message)
|
||||
} else {
|
||||
this.messageQueue.push(message)
|
||||
@ -140,9 +244,23 @@ export class GatewayClient {
|
||||
return this.send({ type: 'req', method, params })
|
||||
}
|
||||
|
||||
async startAgent(params: AgentRequestParams): Promise<{ status: string; runId: string }> {
|
||||
const result = await this.call('agent', params)
|
||||
return result as { status: string; runId: string }
|
||||
async startAgent(
|
||||
params: AgentRequestParams,
|
||||
onFinalResult?: (result: AgentFinalResult) => void
|
||||
): Promise<{ status: string; runId: string }> {
|
||||
// Default to "main" agent if not specified
|
||||
const agentId = params.agentId || 'main'
|
||||
const fullParams = { ...params, agentId }
|
||||
console.log('[Gateway] >>> Sending agent request:', params.message.slice(0, 50), 'agentId:', agentId)
|
||||
const result = await this.call('agent', fullParams) as { status: string; runId: string }
|
||||
console.log('[Gateway] <<< Agent accepted:', JSON.stringify(result))
|
||||
|
||||
// Register callback for final result
|
||||
if (onFinalResult && result.runId) {
|
||||
this.agentCallbacks.set(result.runId, onFinalResult)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
onEvent(event: string, handler: (payload: unknown) => void): () => void {
|
||||
@ -156,6 +274,6 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
return this.ws?.readyState === WebSocket.OPEN && this.handshakeComplete
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,20 +173,41 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true })
|
||||
}, 50)
|
||||
|
||||
// Send to gateway
|
||||
const result = await sendGatewayMessage(textToSend, {
|
||||
// Send to gateway with repo context for workspace management
|
||||
const repoContext = {
|
||||
owner: currentSession.repository.owner,
|
||||
name: currentSession.repository.name,
|
||||
branch: currentSession.repository.defaultBranch,
|
||||
})
|
||||
}
|
||||
console.log('[ChatView] Sending with repoContext:', repoContext)
|
||||
|
||||
// Handle final result (success or error from agent)
|
||||
const handleFinalResult = (finalResult: { status: string; error?: string; summary?: string }) => {
|
||||
console.log('[ChatView] Agent final result:', finalResult)
|
||||
if (finalResult.status === 'error') {
|
||||
setIsRunning(false)
|
||||
setMessages((prev) => {
|
||||
const errorMsg: Message = {
|
||||
id: `msg-${Date.now()}-agent-error`,
|
||||
role: MessageRole.Assistant,
|
||||
content: `⚠️ Agent Error\n\n${finalResult.error || finalResult.summary || 'Unknown error'}\n\nRepo: ${repoContext.owner}/${repoContext.name}\nBranch: ${repoContext.branch}`,
|
||||
timestamp: new Date(),
|
||||
toolCalls: [],
|
||||
}
|
||||
return [...prev, errorMsg]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await sendGatewayMessage(textToSend, repoContext, handleFinalResult)
|
||||
|
||||
if (!result) {
|
||||
// Failed to send - show error
|
||||
// Failed to send - show error with details
|
||||
setMessages((prev) => {
|
||||
const errorMsg: Message = {
|
||||
id: `msg-${Date.now()}-error`,
|
||||
role: MessageRole.Assistant,
|
||||
content: 'Failed to connect to clawdbot gateway. Please check your settings.',
|
||||
content: `Failed to connect to clawdbot gateway.\n\nRepo: ${repoContext.owner}/${repoContext.name}\nBranch: ${repoContext.branch}\n\nPlease check:\n• Gateway is running\n• Settings are correct\n• The repository exists on GitHub`,
|
||||
timestamp: new Date(),
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
@ -20,9 +20,14 @@ const SettingsView: React.FC<Props> = ({ onBack }) => {
|
||||
const { settings, updateSettings } = useSettings()
|
||||
const [githubUsername, setGithubUsername] = useState(settings.githubUsername)
|
||||
const [gatewayUrl, setGatewayUrl] = useState(settings.gatewayUrl)
|
||||
const [gatewayToken, setGatewayToken] = useState(settings.gatewayToken || '')
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateSettings({ githubUsername, gatewayUrl })
|
||||
await updateSettings({
|
||||
githubUsername,
|
||||
gatewayUrl: gatewayUrl.trim(),
|
||||
gatewayToken: gatewayToken.trim() || undefined,
|
||||
})
|
||||
Alert.alert('Settings Saved', 'Your settings have been saved successfully.')
|
||||
}
|
||||
|
||||
@ -61,7 +66,7 @@ const SettingsView: React.FC<Props> = ({ onBack }) => {
|
||||
style={styles.input}
|
||||
value={gatewayUrl}
|
||||
onChangeText={setGatewayUrl}
|
||||
placeholder="ws://localhost:18789"
|
||||
placeholder="ws://192.168.1.x:18789"
|
||||
placeholderTextColor={Colors.secondaryText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@ -69,6 +74,22 @@ const SettingsView: React.FC<Props> = ({ onBack }) => {
|
||||
<Text style={styles.hint}>WebSocket URL of your clawdbot gateway</Text>
|
||||
</View>
|
||||
|
||||
{/* Gateway Token */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Gateway Token (Optional)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={gatewayToken}
|
||||
onChangeText={setGatewayToken}
|
||||
placeholder="Enter gateway token if required"
|
||||
placeholderTextColor={Colors.secondaryText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
<Text style={styles.hint}>Required if gateway auth is enabled (check ~/.clawdbot/clawdbot.json)</Text>
|
||||
</View>
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity style={styles.saveButton} onPress={handleSave}>
|
||||
<Text style={styles.saveButtonText}>Save Settings</Text>
|
||||
|
||||
1
dist/control-ui/assets/index-BPDeGGxb.css
vendored
1
dist/control-ui/assets/index-BPDeGGxb.css
vendored
File diff suppressed because one or more lines are too long
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/control-ui/index.html
vendored
4
dist/control-ui/index.html
vendored
@ -6,8 +6,8 @@
|
||||
<title>Clawdbot Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
|
||||
<script type="module" crossorigin src="./assets/index-B0_pGVGK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B_dnGukZ.css">
|
||||
</head>
|
||||
<body>
|
||||
<clawdbot-app></clawdbot-app>
|
||||
|
||||
4651
pnpm-lock.yaml
generated
4651
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user