This commit is contained in:
semihpolat 2026-01-26 14:02:54 -08:00
parent e7e16478e3
commit 6b8bcee7ea
18 changed files with 5547 additions and 3137 deletions

View File

@ -31,6 +31,7 @@ yarn-error.*
*.pem *.pem
# local env files # local env files
.env
.env*.local .env*.local
# typescript # typescript

View File

@ -16,6 +16,7 @@
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-markdown-display": "^7.0.2",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0" "react-native-screens": "~4.16.0"
}, },
@ -3905,6 +3906,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/caniuse-lite": {
"version": "1.0.30001766", "version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@ -4254,6 +4264,26 @@
"node": ">=8" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -4408,6 +4438,12 @@
"node": ">= 0.8" "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": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@ -6361,6 +6397,15 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "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": { "node_modules/locate-path": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -6427,12 +6472,34 @@
"tmpl": "1.0.5" "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": { "node_modules/marky": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0" "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": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@ -7348,6 +7415,12 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -7426,6 +7499,23 @@
"node": ">= 6" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "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": { "node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1", "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", "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": "*" "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": { "node_modules/react-native-safe-area-context": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "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": ">=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": { "node_modules/undici": {
"version": "6.23.0", "version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",

View File

@ -17,6 +17,7 @@
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-markdown-display": "^7.0.2",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0" "react-native-screens": "~4.16.0"
}, },

View 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

View 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

View File

@ -1,8 +1,9 @@
import React, { useState } from 'react' 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 { Colors, Spacing } from '../theme/colors'
import { Message } from '../models/types' import { Message, MessageRole } from '../models/types'
import ToolCallView from './ToolCallView' import ToolCallView from './ToolCallView'
import MarkdownView from './MarkdownView'
interface Props { interface Props {
message: Message message: Message
@ -23,11 +24,21 @@ const MessageView: React.FC<Props> = ({ message }) => {
}) })
} }
const isUser = message.role === MessageRole.User
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Message content */} {/* Message content */}
{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 */} {/* Tool calls */}
@ -47,10 +58,19 @@ const styles = StyleSheet.create({
container: { container: {
gap: Spacing.MD, gap: Spacing.MD,
}, },
messageContent: { userMessageWrapper: {
fontSize: 16, alignItems: 'flex-end',
color: Colors.primaryText, },
paddingHorizontal: Spacing.LG, assistantMessageWrapper: {
alignItems: 'stretch',
},
userBubble: {
backgroundColor: '#3a3a3a',
borderRadius: 18,
borderBottomRightRadius: 4,
paddingHorizontal: Spacing.MD,
paddingVertical: Spacing.SM,
maxWidth: '80%',
}, },
}) })

View File

@ -82,11 +82,12 @@ const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate })
if (!messageText.trim() || !selectedRepo) return if (!messageText.trim() || !selectedRepo) return
// Convert GitHubRepo to app Repository // Convert GitHubRepo to app Repository
// Use selectedBranch (user's choice) not default_branch
const repository: Repository = { const repository: Repository = {
id: String(selectedRepo.id), id: String(selectedRepo.id),
name: selectedRepo.name, name: selectedRepo.name,
owner: selectedRepo.owner.login, owner: selectedRepo.owner.login,
defaultBranch: selectedRepo.default_branch, defaultBranch: selectedBranch || selectedRepo.default_branch,
language: selectedRepo.language || undefined, language: selectedRepo.language || undefined,
} }

View File

@ -1,8 +1,8 @@
import React from 'react' 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 { Ionicons } from '@expo/vector-icons'
import { Colors, Spacing, Radius } from '../theme/colors' import { Colors, Spacing, Radius } from '../theme/colors'
import { ToolCall } from '../models/types' import { ToolCall, ToolCallStatus } from '../models/types'
import { getToolColor } from '../theme/types' import { getToolColor } from '../theme/types'
interface Props { interface Props {
@ -13,31 +13,103 @@ interface Props {
const ToolCallView: React.FC<Props> = ({ toolCall, isExpanded, onToggle }) => { const ToolCallView: React.FC<Props> = ({ toolCall, isExpanded, onToggle }) => {
const toolColor = getToolColor(toolCall.type) 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 ( return (
<View style={styles.container}> <View style={[styles.container, isFailed && styles.containerFailed]}>
{/* Header */} {/* Header */}
<TouchableOpacity style={styles.header} onPress={onToggle}> <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.toolType, { color: toolColor }]}>{toolCall.type}</Text>
<Text style={styles.toolInput} numberOfLines={1}> <Text style={styles.statusText}>({getStatusText()})</Text>
{toolCall.input} </View>
<Text style={styles.toolInput} numberOfLines={isExpanded ? undefined : 1}>
{getInputDisplay()}
</Text> </Text>
<Ionicons <Ionicons
name={isExpanded ? 'chevron-up' : 'chevron-down'} name={isExpanded ? 'chevron-up' : 'chevron-down'}
size={12} size={16}
color={Colors.tertiaryText} color={Colors.tertiaryText}
/> />
</TouchableOpacity> </TouchableOpacity>
{/* Expanded content */} {/* Expanded input/output */}
{isExpanded && toolCall.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 <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={styles.outputContainer} style={styles.outputScroll}
> >
<Text style={styles.outputText}>{toolCall.output}</Text> <Text style={styles.outputText}>{toolCall.output}</Text>
</ScrollView> </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> </View>
) )
@ -49,6 +121,11 @@ const styles = StyleSheet.create({
borderRadius: Radius.MD, borderRadius: Radius.MD,
overflow: 'hidden', overflow: 'hidden',
marginHorizontal: Spacing.LG, marginHorizontal: Spacing.LG,
borderWidth: 1,
borderColor: 'transparent',
},
containerFailed: {
borderColor: `${Colors.error}40`,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -57,24 +134,73 @@ const styles = StyleSheet.create({
paddingHorizontal: Spacing.MD, paddingHorizontal: Spacing.MD,
paddingVertical: Spacing.SM, paddingVertical: Spacing.SM,
}, },
headerExpanded: {
borderBottomWidth: 1,
borderBottomColor: Colors.chipBorder,
},
toolInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.XS,
},
toolType: { toolType: {
fontSize: 14, fontSize: 13,
fontWeight: '500', fontWeight: '600',
textTransform: 'uppercase',
},
statusText: {
fontSize: 11,
color: Colors.tertiaryText,
}, },
toolInput: { toolInput: {
flex: 1, flex: 1,
fontSize: 14, fontSize: 13,
color: Colors.secondaryText, color: Colors.secondaryText,
fontFamily: 'Menlo',
}, },
outputContainer: { expandedContent: {
backgroundColor: Colors.surfaceBackground, 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, maxHeight: 200,
}, },
outputText: { outputText: {
fontFamily: 'Menlo', fontFamily: 'Menlo',
fontSize: 11, fontSize: 11,
color: Colors.secondaryText, 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,
}, },
}) })

View File

@ -1,7 +1,9 @@
import { useEffect, useState, useRef, useCallback } from 'react' 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' import { useSettings } from './useSettings'
export type { AgentFinalResult }
export type ToolCallEvent = { export type ToolCallEvent = {
toolCallId: string toolCallId: string
name: string name: string
@ -41,7 +43,10 @@ export const useGateway = () => {
if (settingsLoading) return if (settingsLoading) return
if (!settings.gatewayUrl) 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 clientRef.current = client
setState((s) => ({ ...s, connecting: true, error: null })) setState((s) => ({ ...s, connecting: true, error: null }))
@ -49,10 +54,12 @@ export const useGateway = () => {
// Handle agent events // Handle agent events
const unsubscribe = client.onEvent('agent', (payload) => { const unsubscribe = client.onEvent('agent', (payload) => {
const evt = payload as AgentEventPayload const evt = payload as AgentEventPayload
console.log('[useGateway] Agent event:', evt.stream, JSON.stringify(evt.data).slice(0, 100))
switch (evt.stream) { switch (evt.stream) {
case 'tool': { case 'tool': {
const data = evt.data as ToolCallEvent const data = evt.data as ToolCallEvent
console.log('[useGateway] Tool event:', data.name, data.phase)
for (const handler of toolCallHandlersRef.current) { for (const handler of toolCallHandlersRef.current) {
handler(data) handler(data)
} }
@ -60,12 +67,14 @@ export const useGateway = () => {
} }
case 'assistant': { case 'assistant': {
const data = evt.data as AssistantEvent const data = evt.data as AssistantEvent
console.log('[useGateway] Assistant event, delta length:', data.delta?.length ?? 0)
for (const handler of assistantHandlersRef.current) { for (const handler of assistantHandlersRef.current) {
handler(data) handler(data)
} }
break break
} }
case 'lifecycle': { case 'lifecycle': {
console.log('[useGateway] Lifecycle event:', evt.data)
for (const handler of lifecycleHandlersRef.current) { for (const handler of lifecycleHandlersRef.current) {
handler(evt.data) handler(evt.data)
} }
@ -88,11 +97,12 @@ export const useGateway = () => {
clientRef.current = null clientRef.current = null
setState({ connected: false, connecting: false, error: null }) setState({ connected: false, connecting: false, error: null })
} }
}, [settings.gatewayUrl, settingsLoading]) }, [settings.gatewayUrl?.trim(), settingsLoading])
const sendMessage = useCallback(async ( const sendMessage = useCallback(async (
message: string, 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> => { ): Promise<{ status: string; runId: string } | null> => {
const client = clientRef.current const client = clientRef.current
if (!client?.connected) { if (!client?.connected) {
@ -101,11 +111,14 @@ export const useGateway = () => {
} }
try { try {
const result = await client.startAgent({ const result = await client.startAgent(
{
message, message,
repoContext, repoContext,
idempotencyKey: `${Date.now()}-${Math.random().toString(36).slice(2)}`, idempotencyKey: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
}) },
onFinalResult
)
return result return result
} catch (e) { } catch (e) {
console.error('[useGateway] Failed to send message', e) console.error('[useGateway] Failed to send message', e)

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
const SETTINGS_KEY = '@clawdbot_settings' const SETTINGS_KEY = '@clawdbot_settings'
@ -6,24 +6,32 @@ const SETTINGS_KEY = '@clawdbot_settings'
export interface Settings { export interface Settings {
githubUsername: string githubUsername: string
gatewayUrl: string gatewayUrl: string
gatewayToken?: string
} }
// Default settings from environment variables
const DEFAULT_SETTINGS: Settings = { const DEFAULT_SETTINGS: Settings = {
githubUsername: '', githubUsername: process.env.EXPO_PUBLIC_GITHUB_USERNAME || '',
gatewayUrl: 'ws://localhost:18789', gatewayUrl: process.env.EXPO_PUBLIC_GATEWAY_URL || 'ws://localhost:18789',
gatewayToken: process.env.EXPO_PUBLIC_GATEWAY_TOKEN || '',
} }
export const loadSettings = async (): Promise<Settings> => { export const loadSettings = async (): Promise<Settings> => {
try { try {
const raw = await AsyncStorage.getItem(SETTINGS_KEY) const raw = await AsyncStorage.getItem(SETTINGS_KEY)
if (raw) { if (raw) {
const parsed = JSON.parse(raw) const saved = JSON.parse(raw) as Partial<Settings>
return { ...DEFAULT_SETTINGS, ...parsed } // 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 return DEFAULT_SETTINGS
} catch {
return DEFAULT_SETTINGS
}
} }
export const saveSettings = async (settings: Settings): Promise<void> => { export const saveSettings = async (settings: Settings): Promise<void> => {
@ -39,17 +47,17 @@ export const useSettings = () => {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
loadSettings().then((loaded) => { loadSettings().then((s) => {
setSettings(loaded) setSettings(s)
setLoading(false) setLoading(false)
}) })
}, []) }, [])
const updateSettings = async (updates: Partial<Settings>) => { const updateSettings = useCallback(async (updates: Partial<Settings>) => {
const newSettings = { ...settings, ...updates } const newSettings = { ...settings, ...updates }
setSettings(newSettings) setSettings(newSettings)
await saveSettings(newSettings) await saveSettings(newSettings)
} }, [settings])
return { settings, updateSettings, loading } return { settings, updateSettings, loading }
} }

View File

@ -26,6 +26,21 @@ export type AgentEventPayload = {
data: Record<string, unknown> 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 { export class GatewayClient {
private ws: WebSocket | null = null private ws: WebSocket | null = null
private pending = new Map<string, { private pending = new Map<string, {
@ -33,30 +48,52 @@ export class GatewayClient {
reject: (err: Error) => void reject: (err: Error) => void
}>() }>()
private eventHandlers = new Map<string, Set<(payload: unknown) => 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 reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectDelay = 1000 private reconnectDelay = 1000
private messageQueue: string[] = [] 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> { connect(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
this.handshakeComplete = false
this.ws = new WebSocket(this.url) this.ws = new WebSocket(this.url)
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('[Gateway] Connected') 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 // Send queued messages
for (const msg of this.messageQueue) { for (const msg of this.messageQueue) {
this.ws?.send(msg) this.ws?.send(msg)
} }
this.messageQueue = [] this.messageQueue = []
this.reconnectDelay = 1000 // Reset on successful connect
resolve() 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) => { this.ws.onclose = (event) => {
console.log('[Gateway] Disconnected', event.code, event.reason) console.log('[Gateway] Disconnected', event.code, event.reason)
this.handshakeComplete = false
this.scheduleReconnect() this.scheduleReconnect()
} }
this.ws.onerror = (error) => { this.ws.onerror = (error) => {
console.error('[Gateway] Error', error) console.error('[Gateway] Error', error)
reject(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() { disconnect() {
if (this.reconnectTimer) { if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer) clearTimeout(this.reconnectTimer)
@ -76,6 +136,7 @@ export class GatewayClient {
this.ws.close() this.ws.close()
this.ws = null this.ws = null
} }
this.handshakeComplete = false
} }
private scheduleReconnect() { private scheduleReconnect() {
@ -93,20 +154,43 @@ export class GatewayClient {
private handleMessage(data: string) { private handleMessage(data: string) {
try { try {
const frame: GatewayFrame = JSON.parse(data) 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') { if (frame.type === 'res') {
const pending = this.pending.get(frame.id) const pending = this.pending.get(frame.id)
if (pending) { if (pending) {
this.pending.delete(frame.id) this.pending.delete(frame.id)
if (frame.ok) { if (frame.ok) {
console.log('[Gateway] Response OK:', JSON.stringify(frame.payload).slice(0, 200))
pending.resolve(frame.payload) pending.resolve(frame.payload)
} else { } else {
console.log('[Gateway] Response ERROR:', frame.error?.message)
pending.reject(new Error(frame.error?.message || 'Request failed')) 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') { } 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) const handlers = this.eventHandlers.get(frame.event)
if (handlers) { if (handlers) {
console.log('[Gateway] Found', handlers.size, 'handlers for event:', frame.event)
for (const handler of handlers) { for (const handler of handlers) {
try { try {
handler(frame.payload) handler(frame.payload)
@ -114,6 +198,8 @@ export class GatewayClient {
console.error('[Gateway] Event handler error', e) console.error('[Gateway] Event handler error', e)
} }
} }
} else {
console.log('[Gateway] No handlers for event:', frame.event)
} }
} }
} catch (e) { } 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> { private send(frame: Record<string, unknown>): Promise<unknown> {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const message = JSON.stringify({ ...frame, id }) const message = JSON.stringify({ ...frame, id })
@ -128,7 +232,7 @@ export class GatewayClient {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.pending.set(id, { 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) this.ws.send(message)
} else { } else {
this.messageQueue.push(message) this.messageQueue.push(message)
@ -140,9 +244,23 @@ export class GatewayClient {
return this.send({ type: 'req', method, params }) return this.send({ type: 'req', method, params })
} }
async startAgent(params: AgentRequestParams): Promise<{ status: string; runId: string }> { async startAgent(
const result = await this.call('agent', params) params: AgentRequestParams,
return result as { status: string; runId: string } 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 { onEvent(event: string, handler: (payload: unknown) => void): () => void {
@ -156,6 +274,6 @@ export class GatewayClient {
} }
get connected() { get connected() {
return this.ws?.readyState === WebSocket.OPEN return this.ws?.readyState === WebSocket.OPEN && this.handshakeComplete
} }
} }

View File

@ -173,20 +173,41 @@ const ChatView: React.FC<Props> = ({ session, onBack }) => {
scrollViewRef.current?.scrollToEnd({ animated: true }) scrollViewRef.current?.scrollToEnd({ animated: true })
}, 50) }, 50)
// Send to gateway // Send to gateway with repo context for workspace management
const result = await sendGatewayMessage(textToSend, { const repoContext = {
owner: currentSession.repository.owner, owner: currentSession.repository.owner,
name: currentSession.repository.name, name: currentSession.repository.name,
branch: currentSession.repository.defaultBranch, 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) { if (!result) {
// Failed to send - show error // Failed to send - show error with details
setMessages((prev) => { setMessages((prev) => {
const errorMsg: Message = { const errorMsg: Message = {
id: `msg-${Date.now()}-error`, id: `msg-${Date.now()}-error`,
role: MessageRole.Assistant, 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(), timestamp: new Date(),
toolCalls: [], toolCalls: [],
} }

View File

@ -20,9 +20,14 @@ const SettingsView: React.FC<Props> = ({ onBack }) => {
const { settings, updateSettings } = useSettings() const { settings, updateSettings } = useSettings()
const [githubUsername, setGithubUsername] = useState(settings.githubUsername) const [githubUsername, setGithubUsername] = useState(settings.githubUsername)
const [gatewayUrl, setGatewayUrl] = useState(settings.gatewayUrl) const [gatewayUrl, setGatewayUrl] = useState(settings.gatewayUrl)
const [gatewayToken, setGatewayToken] = useState(settings.gatewayToken || '')
const handleSave = async () => { 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.') Alert.alert('Settings Saved', 'Your settings have been saved successfully.')
} }
@ -61,7 +66,7 @@ const SettingsView: React.FC<Props> = ({ onBack }) => {
style={styles.input} style={styles.input}
value={gatewayUrl} value={gatewayUrl}
onChangeText={setGatewayUrl} onChangeText={setGatewayUrl}
placeholder="ws://localhost:18789" placeholder="ws://192.168.1.x:18789"
placeholderTextColor={Colors.secondaryText} placeholderTextColor={Colors.secondaryText}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@ -69,6 +74,22 @@ const SettingsView: React.FC<Props> = ({ onBack }) => {
<Text style={styles.hint}>WebSocket URL of your clawdbot gateway</Text> <Text style={styles.hint}>WebSocket URL of your clawdbot gateway</Text>
</View> </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 */} {/* Save Button */}
<TouchableOpacity style={styles.saveButton} onPress={handleSave}> <TouchableOpacity style={styles.saveButton} onPress={handleSave}>
<Text style={styles.saveButtonText}>Save Settings</Text> <Text style={styles.saveButtonText}>Save Settings</Text>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,8 +6,8 @@
<title>Clawdbot Control</title> <title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" /> <link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script> <script type="module" crossorigin src="./assets/index-B0_pGVGK.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css"> <link rel="stylesheet" crossorigin href="./assets/index-B_dnGukZ.css">
</head> </head>
<body> <body>
<clawdbot-app></clawdbot-app> <clawdbot-app></clawdbot-app>

4651
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff