feat: enhance mobile voice build and UI layout

- Updated AGENTS.md with new build commands for development and production.
- Added a new script in package.json for starting Expo with a specific hostname.
- Improved layout handling in DictationScreen by adding dynamic height calculations for dropdown menus and footers.
- Introduced new state variables to manage menu list heights and footer heights for better UI responsiveness.
- Enhanced QR code generation for mobile pairing in the serve command, allowing for a connect QR option without starting the server.
pull/19545/head
Ryan Vogel 2026-04-02 13:01:17 +00:00
parent c90640e0e1
commit 4d30ad1e7c
5 changed files with 185 additions and 61 deletions

View File

@ -175,3 +175,9 @@ Example shape:
- If behavior could break startup, run `bunx expo export --platform ios --clear`. - If behavior could break startup, run `bunx expo export --platform ios --clear`.
- Confirm no accidental config side effects were introduced. - Confirm no accidental config side effects were introduced.
- Summarize what was verified on-device vs only in tooling. - Summarize what was verified on-device vs only in tooling.
- Dev build (internal/dev client):
- bunx eas build --profile development --platform ios
- Production build + auto-submit:
- bunx eas build --profile production --platform ios --auto-submit

View File

@ -4,6 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"expo:start": "REACT_NATIVE_PACKAGER_HOSTNAME=exos.husky-tilapia.ts.net expo start --dev-client --clear --host lan",
"relay": "echo 'Use packages/apn-relay for APNs relay server'", "relay": "echo 'Use packages/apn-relay for APNs relay server'",
"relay:legacy": "node ./relay/opencode-relay.mjs", "relay:legacy": "node ./relay/opencode-relay.mjs",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",

View File

@ -48,6 +48,10 @@ const WAVEFORM_ROWS = 5
const WAVEFORM_CELL_SIZE = 8 const WAVEFORM_CELL_SIZE = 8
const WAVEFORM_CELL_GAP = 2 const WAVEFORM_CELL_GAP = 2
const DROPDOWN_VISIBLE_ROWS = 6 const DROPDOWN_VISIBLE_ROWS = 6
const DROPDOWN_ROW_HEIGHT = 42
const SERVER_MENU_SECTION_HEIGHT = 56
const SERVER_MENU_ENTRY_HEIGHT = 36
const SERVER_MENU_FOOTER_HEIGHT = 28
// If the press duration is shorter than this, treat it as a tap (toggle) // If the press duration is shorter than this, treat it as a tap (toggle)
const TAP_THRESHOLD_MS = 300 const TAP_THRESHOLD_MS = 300
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json` const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
@ -591,6 +595,10 @@ export default function DictationScreen() {
const [readerModeRendered, setReaderModeRendered] = useState(false) const [readerModeRendered, setReaderModeRendered] = useState(false)
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none") const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server") const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server")
const [serverMenuListHeight, setServerMenuListHeight] = useState(0)
const [sessionMenuListHeight, setSessionMenuListHeight] = useState(0)
const [serverMenuFooterHeight, setServerMenuFooterHeight] = useState(0)
const [sessionMenuFooterHeight, setSessionMenuFooterHeight] = useState(0)
const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null) const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null)
const [scanOpen, setScanOpen] = useState(false) const [scanOpen, setScanOpen] = useState(false)
const [pairSelectionOpen, setPairSelectionOpen] = useState(false) const [pairSelectionOpen, setPairSelectionOpen] = useState(false)
@ -633,6 +641,8 @@ export default function DictationScreen() {
setDropdownMode("none") setDropdownMode("none")
}, []) }, [])
const discoveryEnabled = onboardingComplete && localNetworkPermissionState !== "denied" && dropdownMode === "server"
const { const {
servers, servers,
serversRef, serversRef,
@ -655,7 +665,7 @@ export default function DictationScreen() {
const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery( const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
{ {
enabled: onboardingComplete && localNetworkPermissionState !== "denied", enabled: discoveryEnabled,
}, },
) )
@ -2000,17 +2010,29 @@ export default function DictationScreen() {
], ],
})) }))
const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1) const maxDropdownListHeight = DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT
const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1) const serverMenuEntries = Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42 const estimatedServerMenuRowsHeight = Math.min(
SERVER_MENU_SECTION_HEIGHT + serverMenuEntries * SERVER_MENU_ENTRY_HEIGHT,
maxDropdownListHeight,
)
const sessionMenuRows = Math.max(activeServer?.sessions.length ?? 0, 1)
const estimatedSessionMenuRowsHeight = Math.min(sessionMenuRows, DROPDOWN_VISIBLE_ROWS) * DROPDOWN_ROW_HEIGHT
const serverMenuRowsHeight = Math.min(serverMenuListHeight || estimatedServerMenuRowsHeight, maxDropdownListHeight)
const sessionMenuRowsHeight = Math.min(sessionMenuListHeight || estimatedSessionMenuRowsHeight, maxDropdownListHeight)
const expandedRowsHeight = effectiveDropdownMode === "server" ? serverMenuRowsHeight : sessionMenuRowsHeight
const estimatedSessionFooterHeight = sessionCreationChoiceCount === 2 ? 72 : sessionCreationChoiceCount === 1 ? 38 : 8
const measuredServerFooterHeight = serverMenuFooterHeight || SERVER_MENU_FOOTER_HEIGHT
const measuredSessionFooterHeight = sessionMenuFooterHeight || estimatedSessionFooterHeight
const dropdownFooterExtraHeight = const dropdownFooterExtraHeight =
effectiveDropdownMode === "server" effectiveDropdownMode === "server"
? 38 ? measuredServerFooterHeight
: sessionCreationChoiceCount === 2 : showSessionCreationChoices
? 72 ? measuredSessionFooterHeight
: sessionCreationChoiceCount === 1 : 8
? 38
: 8
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
const animatedHeaderStyle = useAnimatedStyle(() => ({ const animatedHeaderStyle = useAnimatedStyle(() => ({
@ -2104,6 +2126,26 @@ export default function DictationScreen() {
setWaveformLevels(next) setWaveformLevels(next)
}, []) }, [])
const handleServerMenuListLayout = useCallback((event: LayoutChangeEvent) => {
const next = Math.ceil(event.nativeEvent.layout.height)
setServerMenuListHeight((prev) => (prev === next ? prev : next))
}, [])
const handleSessionMenuListLayout = useCallback((event: LayoutChangeEvent) => {
const next = Math.ceil(event.nativeEvent.layout.height)
setSessionMenuListHeight((prev) => (prev === next ? prev : next))
}, [])
const handleServerMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
const next = Math.ceil(event.nativeEvent.layout.height)
setServerMenuFooterHeight((prev) => (prev === next ? prev : next))
}, [])
const handleSessionMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
const next = Math.ceil(event.nativeEvent.layout.height)
setSessionMenuFooterHeight((prev) => (prev === next ? prev : next))
}, [])
const toggleServerMenu = useCallback(() => { const toggleServerMenu = useCallback(() => {
void Haptics.selectionAsync().catch(() => {}) void Haptics.selectionAsync().catch(() => {})
setDropdownMode((prev) => { setDropdownMode((prev) => {
@ -2764,7 +2806,7 @@ export default function DictationScreen() {
bounces={false} bounces={false}
> >
{effectiveDropdownMode === "server" ? ( {effectiveDropdownMode === "server" ? (
<> <View onLayout={handleServerMenuListLayout}>
<Text style={styles.serverGroupLabel}>Saved:</Text> <Text style={styles.serverGroupLabel}>Saved:</Text>
{servers.length === 0 ? ( {servers.length === 0 ? (
@ -2833,9 +2875,9 @@ export default function DictationScreen() {
{discoveryError} {discoveryError}
</Text> </Text>
) : null} ) : null}
</> </View>
) : activeServer ? ( ) : activeServer ? (
<> <View onLayout={handleSessionMenuListLayout}>
{activeSession ? ( {activeSession ? (
<> <>
<View style={styles.currentSessionSummary}> <View style={styles.currentSessionSummary}>
@ -2890,18 +2932,20 @@ export default function DictationScreen() {
</Pressable> </Pressable>
)) ))
)} )}
</> </View>
) : ( ) : (
<Text style={styles.serverEmptyText}>Select a server first</Text> <Text style={styles.serverEmptyText}>Select a server first</Text>
)} )}
</ScrollView> </ScrollView>
{effectiveDropdownMode === "server" ? ( {effectiveDropdownMode === "server" ? (
<Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}> <View onLayout={handleServerMenuFooterLayout}>
<Text style={styles.addServerButtonText}>Add server by scanning QR code</Text> <Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
</Pressable> <Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
</Pressable>
</View>
) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? ( ) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? (
<View style={styles.sessionMenuActions}> <View style={styles.sessionMenuActions} onLayout={handleSessionMenuFooterLayout}>
{activeSession ? ( {activeSession ? (
<Pressable <Pressable
onPress={handleCreateSessionLikeCurrent} onPress={handleCreateSessionLikeCurrent}
@ -3887,14 +3931,14 @@ const styles = StyleSheet.create({
}, },
serverMenuInline: { serverMenuInline: {
marginTop: 8, marginTop: 8,
paddingBottom: 8, paddingBottom: 2,
gap: 4, gap: 4,
}, },
dropdownListViewport: { dropdownListViewport: {
maxHeight: DROPDOWN_VISIBLE_ROWS * 42, maxHeight: DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT,
}, },
dropdownListContent: { dropdownListContent: {
paddingBottom: 2, paddingBottom: 0,
}, },
currentSessionSummary: { currentSessionSummary: {
paddingHorizontal: 4, paddingHorizontal: 4,
@ -4026,10 +4070,11 @@ const styles = StyleSheet.create({
fontWeight: "700", fontWeight: "700",
}, },
addServerButton: { addServerButton: {
marginTop: 10, marginTop: 4,
alignSelf: "center", alignSelf: "center",
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 6, paddingTop: 2,
paddingBottom: 10,
}, },
addServerButtonText: { addServerButtonText: {
color: "#B8BDC9", color: "#B8BDC9",

View File

@ -13,6 +13,14 @@ import * as QRCode from "qrcode"
const log = Log.create({ service: "serve" }) const log = Log.create({ service: "serve" })
type PairPayload = {
v: 1
serverID?: string
relayURL: string
relaySecret: string
hosts: string[]
}
function ipTier(address: string): number { function ipTier(address: string): number {
const parts = address.split(".") const parts = address.split(".")
if (parts.length !== 4) return 4 if (parts.length !== 4) return 4
@ -36,9 +44,10 @@ function advertiseURL(input: string, port: number): string | undefined {
if (!raw) return if (!raw) return
try { try {
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`) const hasScheme = raw.includes("://")
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
if (!parsed.hostname) return if (!parsed.hostname) return
if (!parsed.port) { if (!parsed.port && !hasScheme) {
parsed.port = String(port) parsed.port = String(port)
} }
return norm(`${parsed.protocol}//${parsed.host}`) return norm(`${parsed.protocol}//${parsed.host}`)
@ -47,7 +56,7 @@ function advertiseURL(input: string, port: number): string | undefined {
} }
} }
function hosts(hostname: string, port: number, advertised: string[] = []) { function hosts(hostname: string, port: number, advertised: string[] = [], includeLocal = true) {
const seen = new Set<string>() const seen = new Set<string>()
const preferred: string[] = [] const preferred: string[] = []
const entries: Array<{ url: string; tier: number }> = [] const entries: Array<{ url: string; tier: number }> = []
@ -72,12 +81,15 @@ function hosts(hostname: string, port: number, advertised: string[] = []) {
advertised.forEach(addPreferred) advertised.forEach(addPreferred)
add(hostname) if (includeLocal) {
Object.values(os.networkInterfaces()) add(hostname)
.flatMap((item) => item ?? []) Object.values(os.networkInterfaces())
.filter((item) => item.family === "IPv4" && !item.internal) .flatMap((item) => item ?? [])
.map((item) => item.address) .filter((item) => item.family === "IPv4" && !item.internal)
.forEach(add) .map((item) => item.address)
.forEach(add)
}
entries.sort((a, b) => a.tier - b.tier) entries.sort((a, b) => a.tier - b.tier)
return [...preferred, ...entries.map((item) => item.url)] return [...preferred, ...entries.map((item) => item.url)]
} }
@ -86,11 +98,39 @@ function pairLink(pair: unknown) {
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}` return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}`
} }
function pairServerID(input: { relayURL: string; relaySecret: string }) {
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
}
function secretHash(input: string) { function secretHash(input: string) {
if (!input) return "none" if (!input) return "none"
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...` return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
} }
async function printPairQR(pair: PairPayload) {
const link = pairLink(pair)
const qrConfig = {
type: "terminal" as const,
small: true,
errorCorrectionLevel: "M" as const,
}
log.info("pair qr", {
relayURL: pair.relayURL,
relaySecretHash: secretHash(pair.relaySecret),
serverID: pair.serverID,
hosts: pair.hosts,
hostCount: pair.hosts.length,
hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")),
linkLength: link.length,
qr: qrConfig,
})
const code = await QRCode.toString(link, {
...qrConfig,
})
console.log("scan qr code in mobile app or phone camera")
console.log(code)
}
export const ServeCommand = cmd({ export const ServeCommand = cmd({
command: "serve", command: "serve",
builder: (yargs) => builder: (yargs) =>
@ -107,16 +147,15 @@ export const ServeCommand = cmd({
type: "string", type: "string",
array: true, array: true,
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)", describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
})
.option("connect-qr", {
type: "boolean",
default: false,
describe: "print mobile connect QR and exit without starting the server",
}), }),
describe: "starts a headless opencode server", describe: "starts a headless opencode server",
handler: async (args) => { handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
const relayURL = ( const relayURL = (
args["relay-url"] ?? args["relay-url"] ??
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
@ -136,6 +175,40 @@ export const ServeCommand = cmd({
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim() const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = input || randomBytes(18).toString("base64url") const relaySecret = input || randomBytes(18).toString("base64url")
const connectQR = Boolean(args["connect-qr"])
if (connectQR) {
const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
if (!pairHosts.length) {
console.log("connect qr mode requires at least one valid --advertise-host value")
return
}
if (!input) {
console.log("experimental push relay secret generated")
console.log(
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
)
}
console.log("printing connect qr without starting the server")
await printPairQR({
v: 1,
serverID: pairServerID({ relayURL, relaySecret }),
relayURL,
relaySecret,
hosts: pairHosts,
})
return
}
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
if (!input) { if (!input) {
console.log("experimental push relay secret generated") console.log("experimental push relay secret generated")
console.log( console.log(
@ -155,6 +228,7 @@ export const ServeCommand = cmd({
const pair = started ?? const pair = started ??
PushRelay.pair() ?? { PushRelay.pair() ?? {
v: 1 as const, v: 1 as const,
serverID: pairServerID({ relayURL, relaySecret }),
relayURL, relayURL,
relaySecret, relaySecret,
hosts: hosts(host, port, advertiseHosts), hosts: hosts(host, port, advertiseHosts),
@ -164,27 +238,7 @@ export const ServeCommand = cmd({
} }
if (pair) { if (pair) {
console.log("experimental push relay enabled") console.log("experimental push relay enabled")
const link = pairLink(pair) await printPairQR(pair)
const qrConfig = {
type: "terminal" as const,
small: true,
errorCorrectionLevel: "M" as const,
}
log.info("pair qr", {
relayURL: pair.relayURL,
relaySecretHash: secretHash(pair.relaySecret),
serverID: pair.serverID,
hosts: pair.hosts,
hostCount: pair.hosts.length,
hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")),
linkLength: link.length,
qr: qrConfig,
})
const code = await QRCode.toString(link, {
...qrConfig,
})
console.log("scan qr code in mobile app or phone camera")
console.log(code)
} }
} }

View File

@ -105,9 +105,10 @@ function advertiseURL(input: string, port: number): string | undefined {
if (!raw) return if (!raw) return
try { try {
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`) const hasScheme = raw.includes("://")
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
if (!parsed.hostname) return if (!parsed.hostname) return
if (!parsed.port) { if (!parsed.port && !hasScheme) {
parsed.port = String(port) parsed.port = String(port)
} }
return norm(`${parsed.protocol}//${parsed.host}`) return norm(`${parsed.protocol}//${parsed.host}`)
@ -205,6 +206,22 @@ function fallback(input: Type) {
return "OpenCode reported an error for your session." return "OpenCode reported an error for your session."
} }
function titlePrefix(input: Type) {
if (input === "permission") return "Action Needed"
if (input === "error") return "Error"
return
}
function titleForType(input: Type, title: string) {
const next = text(title)
if (!next) return next
const prefix = titlePrefix(input)
if (!prefix) return next
const tagged = `${prefix}:`
if (next.toLowerCase().startsWith(tagged.toLowerCase())) return next
return `${tagged} ${next}`
}
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> { async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
const out: Notify = { const out: Notify = {
type: input.type, type: input.type,
@ -252,6 +269,7 @@ async function notify(input: { type: Type; sessionID: string }): Promise<Notify>
} }
if (!out.title) out.title = `Session ${input.type}` if (!out.title) out.title = `Session ${input.type}`
out.title = titleForType(input.type, out.title)
if (!out.body) out.body = fallback(input.type) if (!out.body) out.body = fallback(input.type)
return out return out
} }