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
# local env files
.env
.env*.local
# typescript

View File

@ -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",

View File

@ -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"
},

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 { 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%',
},
})

View File

@ -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,
}

View File

@ -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,
},
})

View File

@ -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)

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'
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 }
}

View File

@ -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
}
}

View File

@ -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: [],
}

View File

@ -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>

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>
<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

File diff suppressed because it is too large Load Diff