Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ jobs:
env:
CI: '1'
EXPO_OFFLINE: '1'
- run: bash apps/mobile/android/gradlew -p apps/mobile/android :stream-reveal:testDebugUnitTest -PreactNativeArchitectures=x86_64 --max-workers=2 --console=plain
- run: bash apps/mobile/android/gradlew -p apps/mobile/android :stream-reveal:testDebugUnitTest :hardware-send:testDebugUnitTest -PreactNativeArchitectures=x86_64 --max-workers=2 --console=plain

test-full:
name: test (shard ${{ matrix.shard }})
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- Opening a conversation marks it read on the Host (`channelMarkRead`) whenever the Human is at the newest message, so the chip clears on the phone and on desktop.
- Swipe for pin and mute; long-press opens a native menu.
- On iOS a long-press on a Host conversation row peeks at the chat, like Messages: the row lifts into the system context-menu preview (expo-router `Link.Preview`) showing the conversation itself, and tapping the preview opens it. A plain tap opens the conversation as before.
- A long-press on the app icon lists the three chats opened most recently (Home Screen quick actions on iOS, app shortcuts on Android), newest first, and tapping one opens that chat on its Host. The list updates whenever a chat opens and is kept with the device's saved Host list.

**Count chip**

Expand Down Expand Up @@ -215,6 +216,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- **+** sits outside the capsule, bottom-aligned as the field grows. While dictating it becomes **×** (cancel), and while the mic is held, a red dot. With one trailing button (the mic or Send) the capsule is one row: the field and the button. When the text no longer fits that one row, or with more (attached files, or the mic next to Send), the capsule becomes a rounded rectangle with the same corner radius and stacks like the web composer: the field on top at full width, and a row below with expand on the left and the buttons on the right.
- **+** opens Photos, Camera and File below the composer. Picked files show as removable tiles inside the capsule, up to 10 per message. Send appears once there is text or a file.
- While the field is focused and the clipboard holds an image, a panel above the composer, styled like the @ suggestions (Liquid Glass on iOS 26, otherwise the `sheet` layer), reads "Image on the clipboard" with a paste control. On iOS it is the system Paste button (`UIPasteControl`, through `expo-clipboard`'s `ClipboardPasteButton`, image only, icon only at accessibility text sizes), so no paste-permission prompt appears. On Android it is a text `Button` "Paste image" that reads the clipboard; Android only shows its own brief "pasted from your clipboard" notice. The image attaches as a JPEG tile and the row hides until something new is copied. The @ suggestions take that slot first. A text field cannot take an image paste on its own in React Native, so this is the paste path.
- With a hardware keyboard, **⌘↩** sends on iOS and iPadOS (listed in the ⌘-hold shortcut overlay as "Send"), and on Android **Enter** sends while **Shift+Enter** adds a line. **Ctrl+Enter** also sends on Android. In the full-screen draft editor plain Enter adds a line, and only ⌘↩ or Ctrl+Enter sends. The on-screen keyboard never sends; its return key adds a line as before. The local `hardware-send` module listens on the view around the field, so it only acts while the field is focused and the draft can be sent.
- Sending uploads each file to the Host first. A retry keeps the uploads that already finished.
- Nothing sits under the composer, so the chat stays clean. A DM's Model and Workspace settings live in the Bot profile, in a **Bot settings** group under the profile actions: a **Model** row (the Bot's current model name, or "Loading…", "No model set" or "Unavailable") and a **Workspace** row (the folder count, "N folders"), each with a chevron.
- **Model** opens the native Models and presets page inside the profile drawer: Orchestrator, Assignment default, allowed Assignment models, reasoning effort, restore global default, save as preset and apply a shared preset. The searchable chooser pushes inside that same drawer and returns only after an accepted save and canonical refresh. Editing only the Orchestrator preserves Assignment policy. Assignment edits retain other allowed models, efforts and notes; the current default cannot be removed until another is chosen. Existing preset copies and per-Bot plans retain the Host's revision checks. Global default updates use the Host's existing profile-default contract; mobile does not create a separate global Assignment authority.
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/modules/hardware-send/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Hardware keyboard send

This local Expo module wraps the chat composer's text field so a hardware
keyboard can send the draft. It owns no text and no layout: the field, its
draft and the send action stay in React Native, and the view only reports
`onSend`.

- **iOS and iPadOS:** the view adds a `UIKeyCommand` for ⌘↩. UIKit collects key
commands along the responder chain, so it applies only while the field inside
is first responder. The command title appears in the ⌘-hold shortcut overlay.
- **Android:** the view intercepts `dispatchKeyEvent` before the focused
`EditText`. Enter sends when `enter` is set, Shift+Enter always adds a line,
and Ctrl+Enter or Meta+Enter always sends. Key events flagged as coming from
the on-screen keyboard, or from a virtual input device, pass through
unchanged. Like `stream-reveal`, the view keeps the size Yoga gives it and
leaves child layout to React Native.

When `enabled` is false the view does nothing, so an empty draft or a
dictation in progress never sends. The binding lives in
`src/ui/primitives/hardware-send.*.tsx`; builds without the module fall back to
a plain `View`. **Adding this module requires a native rebuild**; an OTA update
alone cannot install it.

After Expo prebuild, run the Android unit tests from the repository root:

```sh
pnpm --filter @botharness/mobile exec expo prebuild --platform android --no-install
bash apps/mobile/android/gradlew -p apps/mobile/android :hardware-send:testDebugUnitTest
```
17 changes: 17 additions & 0 deletions apps/mobile/modules/hardware-send/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apply plugin: 'com.android.library'
apply plugin: 'expo-module-gradle-plugin'

group = 'expo.modules.hardwaresend'
version = '0.1.0'

android {
namespace 'expo.modules.hardwaresend'
defaultConfig { versionCode 1; versionName '0.1.0' }
testOptions { unitTests.includeAndroidResources = true }
}

dependencies {
testImplementation 'com.facebook.react:react-android'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.14.1'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package expo.modules.hardwaresend

import android.view.View
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class HardwareSendModule : Module() {
override fun definition() = ModuleDefinition {
Name("HardwareSend")
View(HardwareSendView::class) {
Events("onSend")
Prop("enabled") { view: HardwareSendView, value: Boolean -> view.sending = value }
Prop("enter") { view: HardwareSendView, value: Boolean -> view.enter = value }
GroupView<HardwareSendView> {
AddChildView { parent: HardwareSendView, child: View, index: Int -> parent.addView(child, index) }
GetChildCount { parent -> parent.childCount }
GetChildViewAt { parent, index -> parent.getChildAt(index) }
RemoveChildView { parent: HardwareSendView, child: View -> parent.removeView(child) }
RemoveChildViewAt { parent, index -> parent.removeViewAt(index) }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package expo.modules.hardwaresend

import android.content.Context
import android.view.KeyEvent
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView

internal fun sendsOnKey(keyCode: Int, meta: Int, enter: Boolean, soft: Boolean): Boolean {
if (soft) return false
if (keyCode != KeyEvent.KEYCODE_ENTER && keyCode != KeyEvent.KEYCODE_NUMPAD_ENTER) return false
if (meta and KeyEvent.META_SHIFT_ON != 0) return false
if (meta and (KeyEvent.META_CTRL_ON or KeyEvent.META_META_ON) != 0) return true
return enter && meta and KeyEvent.META_ALT_ON == 0
}

internal fun fromSoftKeyboard(event: KeyEvent): Boolean =
event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0 || event.device?.isVirtual == true

class HardwareSendView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
var sending = false
var enter = false
private val onSend by EventDispatcher()

override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (!sending || !sendsOnKey(event.keyCode, event.metaState, enter, fromSoftKeyboard(event))) {
return super.dispatchKeyEvent(event)
}
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) onSend(mapOf<String, Any>())
return true
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec))
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) = Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package expo.modules.hardwaresend

import android.view.KeyEvent
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class HardwareSendTest {
private val enterKey = KeyEvent.KEYCODE_ENTER

@Test fun plainEnterSendsOnlyWhereEnterSends() {
assertTrue(sendsOnKey(enterKey, 0, enter = true, soft = false))
assertTrue(sendsOnKey(KeyEvent.KEYCODE_NUMPAD_ENTER, 0, enter = true, soft = false))
assertFalse(sendsOnKey(enterKey, 0, enter = false, soft = false))
}

@Test fun shiftEnterAlwaysAddsALine() {
assertFalse(sendsOnKey(enterKey, KeyEvent.META_SHIFT_ON, enter = true, soft = false))
assertFalse(
sendsOnKey(enterKey, KeyEvent.META_SHIFT_ON or KeyEvent.META_CTRL_ON, enter = true, soft = false),
)
}

@Test fun ctrlOrMetaEnterSendsEvenInTheFullEditor() {
assertTrue(sendsOnKey(enterKey, KeyEvent.META_CTRL_ON, enter = false, soft = false))
assertTrue(sendsOnKey(enterKey, KeyEvent.META_META_ON, enter = false, soft = false))
}

@Test fun altEnterAndOtherKeysPassThrough() {
assertFalse(sendsOnKey(enterKey, KeyEvent.META_ALT_ON, enter = true, soft = false))
assertFalse(sendsOnKey(KeyEvent.KEYCODE_A, KeyEvent.META_CTRL_ON, enter = true, soft = false))
}

@Test fun theOnScreenKeyboardNeverSends() {
assertFalse(sendsOnKey(enterKey, 0, enter = true, soft = true))
assertFalse(sendsOnKey(enterKey, KeyEvent.META_CTRL_ON, enter = true, soft = true))
}
}
9 changes: 9 additions & 0 deletions apps/mobile/modules/hardware-send/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["HardwareSendModule"]
},
"android": {
"modules": ["expo.modules.hardwaresend.HardwareSendModule"]
}
}
21 changes: 21 additions & 0 deletions apps/mobile/modules/hardware-send/ios/HardwareSend.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'HardwareSend'
s.version = '1.0.0'
s.summary = 'Sends the chat draft from a hardware keyboard shortcut.'
s.description = 'Sends the chat draft from a hardware keyboard shortcut.'
s.license = 'MIT'
s.author = 'BotHarness'
s.homepage = 'https://github.com/BotHarness/DeepSeekBot'
s.platforms = { :ios => '16.4' }
s.swift_version = '5.9'
s.source = { git: 'https://github.com/BotHarness/DeepSeekBot.git' }
s.static_framework = true

s.dependency 'ExpoModulesCore'

s.source_files = '**/*.{h,m,swift}'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
end
37 changes: 37 additions & 0 deletions apps/mobile/modules/hardware-send/ios/HardwareSendModule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import ExpoModulesCore
import UIKit

public final class HardwareSendModule: Module {
public func definition() -> ModuleDefinition {
Name("HardwareSend")

View(HardwareSendView.self) {
Events("onSend")
Prop("enabled") { (view: HardwareSendView, enabled: Bool) in
view.enabled = enabled
}
Prop("title") { (view: HardwareSendView, title: String) in
view.title = title
}
}
}
}

final class HardwareSendView: ExpoView {
let onSend = EventDispatcher()
var enabled = false
var title = ""

override var keyCommands: [UIKeyCommand]? {
guard enabled else { return super.keyCommands }
let command = UIKeyCommand(
title: title, action: #selector(send), input: "\r", modifierFlags: .command)
command.wantsPriorityOverSystemBehavior = true
return [command] + (super.keyCommands ?? [])
}

@objc private func send() {
guard enabled else { return }
onSend()
}
}
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"expo-image-picker": "~58.0.10",
"expo-linking": "~58.0.13",
"expo-notifications": "~58.1.5",
"expo-quick-actions": "6.0.2",
"expo-router": "~58.0.24",
"expo-secure-store": "~58.0.2",
"expo-sharing": "~58.0.19",
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/ui/chat/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { PickSource, PickedFile } from '../../host/pick-files';
import { pressTint, useDesignTheme } from '../design/theme';
import { edge, motion, radius, space } from '../design/tokens';
import { HardwareSend } from '../primitives/hardware-send';
import { Text } from '../primitives/text';
import {
ArrowUpIcon,
Expand Down Expand Up @@ -617,14 +618,20 @@ export function Composer({
</ScrollView>
) : null}
<View style={{ padding: inset }}>
<View
<HardwareSend
enabled={actions.includes('send') && !listening}
enter
onSend={() => {
haptic('send');
onSend();
}}
style={{
flexDirection: 'row',
paddingRight: stacked || trailing.length === 0 ? 0 : button + space.x2,
}}
>
{field}
</View>
</HardwareSend>
{stacked && expand === null && trailing.length === 0 ? null : (
<View
pointerEvents="box-none"
Expand Down
21 changes: 20 additions & 1 deletion apps/mobile/src/ui/chat/conversation-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { ScrollEdgeElement, nativeScrollEdge } from '../primitives/scroll-edge-e
import { barLabelStyle, glassHeader, useHeaderOverlap } from './native-header';
import { useFollowLatest } from './follow-latest';
import { usePullToCompose } from './pull-to-compose';
import { chatHref, useRecentChat } from '../shell/recent-chats';
import { PullToComposeHint } from './pull-to-compose-hint';
import { useMarkRead } from './mark-read';
import { useBotNames } from './use-bot-names';
Expand Down Expand Up @@ -446,13 +447,31 @@ function ChatConversation({
[scroll, viewport, content, viewportLayout, focused],
);
const readMount = useMarkRead(client, inbox, conversation, activeChannel, !latest.away);
const recentMount = useRecentChat(
botDm
? undefined
: bot !== undefined
? {
id: `dm:${saved?.hostId ?? ''}:${bot.slug}`,
title: bot.displayName,
href: chatHref(`/dm/${encodeURIComponent(bot.slug)}`, saved?.hostId),
}
: channel !== undefined
? {
id: `channel:${saved?.hostId ?? ''}:${channel.id}`,
title: channel.name,
href: chatHref(`/channel/${encodeURIComponent(channel.id)}`, saved?.hostId),
}
: undefined,
);
const mountRef = dictation.mountRef;
const screenRef = useCallback(
(node: unknown) => {
mountRef(node);
readMount(node);
recentMount(node);
},
[mountRef, readMount],
[mountRef, readMount, recentMount],
);

const asking = snapshot.messages.findLast(
Expand Down
55 changes: 29 additions & 26 deletions apps/mobile/src/ui/chat/draft-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { useDesignTheme } from '../design/theme';
import { space } from '../design/tokens';
import { HardwareSend } from '../primitives/hardware-send';
import { TextInput } from '../primitives/system-text';
import { Text } from '../primitives/text';
import { ArrowUpIcon, CheckIcon } from '../shell/icons';
Expand Down Expand Up @@ -70,32 +71,34 @@ export function DraftEditor({
) : null}
</View>
{above}
<TextInput
autoFocus
multiline
value={value}
onChangeText={onChange}
{...(selection === undefined ? {} : { selection })}
{...(onSelection === undefined
? {}
: {
onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) =>
onSelection(event.nativeEvent.selection.end),
})}
placeholder={placeholder}
accessibilityLabel={t.editDraft}
placeholderTextColor={theme.color.subtle}
style={{
flex: 1,
textAlignVertical: 'top',
paddingHorizontal: space.x5,
paddingTop: space.x3,
paddingBottom: Math.max(insets.bottom, space.x3),
color: theme.color.fg,
fontSize: theme.type.body.size,
lineHeight: theme.type.body.line,
}}
/>
<HardwareSend enabled={canSend} enter={false} onSend={onSend} style={{ flex: 1 }}>
<TextInput
autoFocus
multiline
value={value}
onChangeText={onChange}
{...(selection === undefined ? {} : { selection })}
{...(onSelection === undefined
? {}
: {
onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) =>
onSelection(event.nativeEvent.selection.end),
})}
placeholder={placeholder}
accessibilityLabel={t.editDraft}
placeholderTextColor={theme.color.subtle}
style={{
flex: 1,
textAlignVertical: 'top',
paddingHorizontal: space.x5,
paddingTop: space.x3,
paddingBottom: Math.max(insets.bottom, space.x3),
color: theme.color.fg,
fontSize: theme.type.body.size,
lineHeight: theme.type.body.line,
}}
/>
</HardwareSend>
</KeyboardAvoidingView>
</Modal>
);
Expand Down
Loading
Loading