Sfoglia il codice sorgente

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.
Ryan Vogel 4 mesi fa
parent
commit
4d30ad1e7c

+ 6 - 0
packages/mobile-voice/AGENTS.md

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

+ 1 - 0
packages/mobile-voice/package.json

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

+ 68 - 23
packages/mobile-voice/src/app/index.tsx

@@ -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,17 +2010,29 @@ 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
-          : 8
+      ? measuredServerFooterHeight
+      : showSessionCreationChoices
+        ? measuredSessionFooterHeight
+        : 8
   const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
 
   const animatedHeaderStyle = useAnimatedStyle(() => ({
@@ -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" ? (
-              <Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
-                <Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
-              </Pressable>
+              <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",

+ 90 - 36
packages/opencode/src/cli/cmd/serve.ts

@@ -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)
 
-  add(hostname)
-  Object.values(os.networkInterfaces())
-    .flatMap((item) => item ?? [])
-    .filter((item) => item.family === "IPv4" && !item.internal)
-    .map((item) => item.address)
-    .forEach(add)
+  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,11 +98,39 @@ 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)}...`
 }
 
+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({
   command: "serve",
   builder: (yargs) =>
@@ -107,16 +147,15 @@ export const ServeCommand = cmd({
         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) => {
-    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 ??
@@ -136,6 +175,40 @@ export const ServeCommand = cmd({
 
     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(
@@ -155,6 +228,7 @@ export const ServeCommand = cmd({
       const pair = started ??
         PushRelay.pair() ?? {
           v: 1 as const,
+          serverID: pairServerID({ relayURL, relaySecret }),
           relayURL,
           relaySecret,
           hosts: hosts(host, port, advertiseHosts),
@@ -164,27 +238,7 @@ export const ServeCommand = cmd({
       }
       if (pair) {
         console.log("experimental push relay enabled")
-        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)
+        await printPairQR(pair)
       }
     }
 

+ 20 - 2
packages/opencode/src/server/push-relay.ts

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