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`.
- Confirm no accidental config side effects were introduced.
- 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",
"scripts": {
"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:legacy": "node ./relay/opencode-relay.mjs",
"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_GAP = 2
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)
const TAP_THRESHOLD_MS = 300
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
@ -591,6 +595,10 @@ export default function DictationScreen() {
const [readerModeRendered, setReaderModeRendered] = useState(false)
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
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 [scanOpen, setScanOpen] = useState(false)
const [pairSelectionOpen, setPairSelectionOpen] = useState(false)
@ -633,6 +641,8 @@ export default function DictationScreen() {
setDropdownMode("none")
}, [])
const discoveryEnabled = onboardingComplete && localNetworkPermissionState !== "denied" && dropdownMode === "server"
const {
servers,
serversRef,
@ -655,7 +665,7 @@ export default function DictationScreen() {
const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
{
enabled: onboardingComplete && localNetworkPermissionState !== "denied",
enabled: discoveryEnabled,
},
)
@ -2000,16 +2010,28 @@ export default function DictationScreen() {
],
}))
const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1)
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
const maxDropdownListHeight = DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT
const serverMenuEntries = Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
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 =
effectiveDropdownMode === "server"
? 38
: sessionCreationChoiceCount === 2
? 72
: sessionCreationChoiceCount === 1
? 38
? measuredServerFooterHeight
: showSessionCreationChoices
? measuredSessionFooterHeight
: 8
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
@ -2104,6 +2126,26 @@ export default function DictationScreen() {
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(() => {
void Haptics.selectionAsync().catch(() => {})
setDropdownMode((prev) => {
@ -2764,7 +2806,7 @@ export default function DictationScreen() {
bounces={false}
>
{effectiveDropdownMode === "server" ? (
<>
<View onLayout={handleServerMenuListLayout}>
<Text style={styles.serverGroupLabel}>Saved:</Text>
{servers.length === 0 ? (
@ -2833,9 +2875,9 @@ export default function DictationScreen() {
{discoveryError}
</Text>
) : null}
</>
</View>
) : activeServer ? (
<>
<View onLayout={handleSessionMenuListLayout}>
{activeSession ? (
<>
<View style={styles.currentSessionSummary}>
@ -2890,18 +2932,20 @@ export default function DictationScreen() {
</Pressable>
))
)}
</>
</View>
) : (
<Text style={styles.serverEmptyText}>Select a server first</Text>
)}
</ScrollView>
{effectiveDropdownMode === "server" ? (
<View onLayout={handleServerMenuFooterLayout}>
<Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
<Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
</Pressable>
</View>
) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? (
<View style={styles.sessionMenuActions}>
<View style={styles.sessionMenuActions} onLayout={handleSessionMenuFooterLayout}>
{activeSession ? (
<Pressable
onPress={handleCreateSessionLikeCurrent}
@ -3887,14 +3931,14 @@ const styles = StyleSheet.create({
},
serverMenuInline: {
marginTop: 8,
paddingBottom: 8,
paddingBottom: 2,
gap: 4,
},
dropdownListViewport: {
maxHeight: DROPDOWN_VISIBLE_ROWS * 42,
maxHeight: DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT,
},
dropdownListContent: {
paddingBottom: 2,
paddingBottom: 0,
},
currentSessionSummary: {
paddingHorizontal: 4,
@ -4026,10 +4070,11 @@ const styles = StyleSheet.create({
fontWeight: "700",
},
addServerButton: {
marginTop: 10,
marginTop: 4,
alignSelf: "center",
paddingHorizontal: 8,
paddingVertical: 6,
paddingTop: 2,
paddingBottom: 10,
},
addServerButtonText: {
color: "#B8BDC9",

View File

@ -13,6 +13,14 @@ import * as QRCode from "qrcode"
const log = Log.create({ service: "serve" })
type PairPayload = {
v: 1
serverID?: string
relayURL: string
relaySecret: string
hosts: string[]
}
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
@ -36,9 +44,10 @@ function advertiseURL(input: string, port: number): string | undefined {
if (!raw) return
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.port) {
if (!parsed.port && !hasScheme) {
parsed.port = String(port)
}
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 preferred: string[] = []
const entries: Array<{ url: string; tier: number }> = []
@ -72,12 +81,15 @@ function hosts(hostname: string, port: number, advertised: string[] = []) {
advertised.forEach(addPreferred)
if (includeLocal) {
add(hostname)
Object.values(os.networkInterfaces())
.flatMap((item) => item ?? [])
.filter((item) => item.family === "IPv4" && !item.internal)
.map((item) => item.address)
.forEach(add)
}
entries.sort((a, b) => a.tier - b.tier)
return [...preferred, ...entries.map((item) => item.url)]
}
@ -86,84 +98,16 @@ function pairLink(pair: unknown) {
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) {
if (!input) return "none"
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
}
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) =>
withNetworkOptions(yargs)
.option("relay-url", {
type: "string",
describe: "experimental APN relay URL",
})
.option("relay-secret", {
type: "string",
describe: "experimental APN relay secret",
})
.option("advertise-host", {
type: "string",
array: true,
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
}),
describe: "starts a headless opencode server",
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 server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
const relayURL = (
args["relay-url"] ??
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
"https://apn.dev.opencode.ai"
).trim()
const advertiseHostArg = args["advertise-host"]
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
? advertiseHostArg
: typeof advertiseHostArg === "string"
? [advertiseHostArg]
: []
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])]
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = input || randomBytes(18).toString("base64url")
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",
)
}
if (relayURL && relaySecret) {
const host = server.hostname ?? opts.hostname
const port = server.port || opts.port || 4096
const started = PushRelay.start({
relayURL,
relaySecret,
hostname: host,
port,
advertiseHosts,
})
const pair = started ??
PushRelay.pair() ?? {
v: 1 as const,
relayURL,
relaySecret,
hosts: hosts(host, port, advertiseHosts),
}
if (!started) {
console.log("experimental push relay failed to initialize; showing setup qr anyway")
}
if (pair) {
console.log("experimental push relay enabled")
async function printPairQR(pair: PairPayload) {
const link = pairLink(pair)
const qrConfig = {
type: "terminal" as const,
@ -185,6 +129,116 @@ export const ServeCommand = cmd({
})
console.log("scan qr code in mobile app or phone camera")
console.log(code)
}
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) =>
withNetworkOptions(yargs)
.option("relay-url", {
type: "string",
describe: "experimental APN relay URL",
})
.option("relay-secret", {
type: "string",
describe: "experimental APN relay secret",
})
.option("advertise-host", {
type: "string",
array: true,
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",
handler: async (args) => {
const opts = await resolveNetworkOptions(args)
const relayURL = (
args["relay-url"] ??
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
"https://apn.dev.opencode.ai"
).trim()
const advertiseHostArg = args["advertise-host"]
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
? advertiseHostArg
: typeof advertiseHostArg === "string"
? [advertiseHostArg]
: []
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])]
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
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) {
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",
)
}
if (relayURL && relaySecret) {
const host = server.hostname ?? opts.hostname
const port = server.port || opts.port || 4096
const started = PushRelay.start({
relayURL,
relaySecret,
hostname: host,
port,
advertiseHosts,
})
const pair = started ??
PushRelay.pair() ?? {
v: 1 as const,
serverID: pairServerID({ relayURL, relaySecret }),
relayURL,
relaySecret,
hosts: hosts(host, port, advertiseHosts),
}
if (!started) {
console.log("experimental push relay failed to initialize; showing setup qr anyway")
}
if (pair) {
console.log("experimental push relay enabled")
await printPairQR(pair)
}
}

View File

@ -105,9 +105,10 @@ function advertiseURL(input: string, port: number): string | undefined {
if (!raw) return
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.port) {
if (!parsed.port && !hasScheme) {
parsed.port = String(port)
}
return norm(`${parsed.protocol}//${parsed.host}`)
@ -205,6 +206,22 @@ function fallback(input: Type) {
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> {
const out: Notify = {
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}`
out.title = titleForType(input.type, out.title)
if (!out.body) out.body = fallback(input.type)
return out
}