ios: attach photos and files from the composer - #3
Open
mnthr7 wants to merge 41 commits into
Open
Conversation
Cost and token data was on the wire and the UI threw it away — but the
harness also never had a consistent number to keep: thread.token-usage
.updated means a per-call delta on Claude, a running thread total on
Codex, and a per-step figure plus a turn total on Antigravity, so summing
it double-counted. And only Claude reports a price at all.
- turn.completed gains usage {input, output}: THIS turn's total, from
what each driver already has (Claude result.usage incl. cache reads,
Codex tokenUsage.last ?? total, Antigravity result.usage). The live
indicator is unchanged and never summed.
- ProviderSnapshot.billing: Claude and Codex both strip API keys and run
on the CLI's own login, so both report "subscription" — a Claude cost
is an equivalent, and the UI captions it that way.
- store.addTaskUsage banks each settled turn onto TaskRecord.usage
{input, output, costUsd, turns}; flows to clients via wireTask.
- UI: header chip for the open task ("12.4k tok · $0.06", cost only
when known); a Usage card in bot settings; a Usage tab in app
settings ranking bots by cost then tokens with a fleet total.
Item 1.5 of docs/plans/agent-harness-upgrades-v2.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main's milind-soni#192 landed its own per-task usage tally (last token-usage event per turn, no cost). Reconciled: TaskRecord.usage keeps the superset shape {input, output, costUsd, turns}; one addTaskUsage (with main's NaN/negative sanitizing); at turn.completed the driver's own per-turn figure (turn.completed.usage) is authoritative and main's last-reported value is the fallback for drivers that only stream the running indicator. Records written before cost existed read costUsd as null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plus menu offers Attach Image, Take Photo, and Choose File. The sidecar writes the bytes under ~/.openmausbot-companion/inbox and the phone sends the same <attached-file path="…"> tag the desktop composer already uses — the harness has no upload route. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Destroying the request in the body reader raced the status line off the socket, so a client saw a hung connection instead of the ceiling. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
mnthr7
marked this pull request as ready for review
August 18, 2026 03:19
* Integrate exact search landing with transcript windows * Address search landing review feedback
PendingAttachment stores a host Attachment.File. The App target does not see CompanionCore types without the import, which is the two-error Xcode build. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Capsule uses half the field's height as its radius, so a few lines of text turn the input into a fat oval. A fixed 20pt corner stays a pill on one line and matches other chat apps when it grows. Actions pin to the last line. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Markdown was not lost — the phone already splits headings, lists, fences and emphasis. Pipe tables were the gap: they fell through as paragraphs of `|` characters while desktop remark-gfm drew a real table. Same GFM delimiter-column rule as the desktop, scroll sideways when it does not fit. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
main gained milind-soni#205 (token spend in the task picker, using the same TaskRecord.usage), the store change stream, activity states, and typed approvals. Reconciled: one addTaskUsage with the superset shape (costUsd kept; emits a bot change like main's did), settle path banks the turn's authoritative usage then moves the bot to idle via setActivity, the Usage settings tab sits beside main's new Companion tab. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agent still receives <attached-file path="…">. The bubble now splits that into caption plus files, draws the image, and falls back to a named chip. GET /api/inbox/:name serves only inbox basenames so a thread can show the photo after a restart. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
The generated project listed every Swift file. After a pull with Xcode still open, that list pointed at CameraPicker.swift and SpeechDictation.swift that were not on disk. An Xcode 16 synced folder compiles whatever Git actually checked out. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
The chip with the inbox filename was the fallback after GET /api/inbox failed (usually an old sidecar). Bytes the phone already uploaded are now cached on device, and a failed fetch is a retry rather than a permanent chip. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
…cost-visibility Show what each bot spends
GET /api/inbox refuses symlinks, directories, and bodies over the ceiling. The phone cache will not follow `..`, the GET client rejects a traversal name, Files folders are skipped, and the roster shows the original filename rather than the timestamp-hex prefix. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Resolve conflicts with milind-soni#214 conversation parity: keep inbox routes and the attach/dictation composer alongside search, tasks, share, and reactions. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
…ion (milind-soni#52) * ui: give the app a focus state, a brand selection, and honest reduced motion Four foundations the design system was missing. All of it lives in src/styles.css with no component changes, so it composes with everything currently in flight. FOCUS. Nine components clear the UA outline with `outline-none`, and nothing anywhere put a ring back — there is not one `focus-visible:` utility in the app. Measured in the running app, all eight text fields focused invisibly: the composer, sidebar search, the answer box on an approval card, both onboarding fields, and all three API key fields. A keyboard user filling in App Settings could not tell which key they were typing into. Buttons kept the browser default, which is legible but reads as an unstyled page inside a hand-tuned dark UI. The rules are deliberately unlayered. Tailwind ships utilities in @layer utilities and unlayered styles outrank every layer, so this beats `outline-none` with no !important and without editing the nine components — every one of which has an open PR against it. Verified: a mouse click still paints nothing, Tab paints a 2px accent ring. The generic ring sets no radius, because an outline already follows the element's own corners and 26 controls here are `rounded-full`. Text fields are the exception: most carry a radius already, but the composer input has none, so its ring came out a hard rectangle inside a pill. SELECTION. ::selection was never styled, so dragging over a bot's reply painted Chrome's default blue through a palette that was pixel-sampled off the real app. It now tints the accent. REDUCED MOTION. The mascot was the only thing honouring prefers-reduced-motion. Panels still flew in, cards still popped, and every hover transition still ran. Now the app stills: panel-in 0.24s and pop-in 0.2s both collapse to 0.01ms under reduce, and normal playback is untouched without it. Durations collapse rather than `animation: none`, because a spinner frozen mid-turn reads as a hung app. The scrollbar thumb also gets a hover state; it was inert. No palette values changed, no radii tokens, no type. Nothing here restyles what was already designed — it fills in what had no styling. * Address focus and reduced-motion review --------- Co-authored-by: milind-soni <milindsoni201@gmail.com>
…ule (milind-soni#217) The bundling fix in milind-soni#198 traded one silent packaging failure for a subtler one. esbuild inlines drivers/claude.ts and drivers/acp/core.ts into index.js at the server root, so the `".."` each wrote to reach a sibling proxy started climbing from the bundle's directory instead of its own — one level too high, two for the ACP driver: PROXY_PATH <Resources>/computer-proxy.js missing PERM_PROXY_PATH <Resources>/permission-proxy.js missing DWEB_PROXY_PATH <Resources>/drivers/dweb-proxy.js missing COMPUTER_PROXY_PATH <Resources>/../computer-proxy.js missing The resolver only stats the .ts branch, so the missing .js was returned unchecked and nothing failed until a child was spawned. The server still booted and /api/health still answered — which is exactly why the new smoke test and the Windows gate both passed the broken build. Impact had it shipped: permission Allow/Deny cards never appear (the default permissionMode is acceptEdits, so every Claude turn takes that branch), cloud-box bots lose mcp__computer, dweb bots lose mcp__dweb, and every ACP engine — grok, gemini, kimi, droid, qwen, hermes — loses its computer proxy. Resolve all five through one anchor in server/proxy-paths.ts, which sits at the server root and is only ever inlined into root-level entries, so the anchor is right in the dev tree and in the bundle. The smoke test now asserts every path in SPAWNED_PROXIES exists inside the staged copy; mutation-checked by restoring the old "..", which fails it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Give rooms a working folder — a shared desk for every member turn A room's bots each worked in their own private workspace, so a team could never collaborate on one project's files. Rooms now carry a cwd (settable from a Working folder card + header chip in the room view, validated by the same path validator bots use), and the room's thread pins its own copy on the first turn that dispatches — mirroring task pinning, because engines key sessions to the folder a thread starts in, so a later folder change applies to future rooms rather than moving a working room. Member turns run in the pinned room folder, overriding the member's own; off-host members (Grok API, cloud box) skip it, and a room with no folder keeps each member's own workspace exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep room folder pinning host-only and immutable --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ni#220) A message sent mid-turn used to bounce with a 409 and a 'try again later'. Now it lands in the transcript immediately — persisted, marked queued — and auto-sends when the bot settles: every queued message for the thread drains into ONE follow-up turn (texts joined), so a burst of steering notes costs one turn. Stop-then-steer is deliberate: an interrupted turn drains too, because these are the user's own words, not a bot's fan-out. The queue itself is memory-only on purpose — each queued message is already an ordinary persisted thread message, so a restart loses only the auto-send intent, never the words, and the client shows the queued affordance only while the bot is busy so a stranded flag is invisible rather than a false promise. Drained turns are plain attended turns: no automationSource, no unattended marking, no comms depth — exactly what typing the same words into an idle bot would run. Drain triggers on turn.completed and on the two settle paths that never emit it (dispatch failure, provider reload). 1:1 chats only; rooms keep their existing client-side hold-one behavior. branching.test.ts's 'second send while busy is 409' assertion encoded the old contract; it now asserts 202 + queued + still exactly one live turn, and stops the drained turn before the edit-fork half of the test. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bring in native notifications, App Store materials, and later main commits. Keep attach, dictation, and inbox alongside those. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Open inbox files by descriptor with O_NOFOLLOW, drain oversized POSTs instead of destroying the socket, and keep GFM tables from dropping streamed cells. Downsample previews and refuse files before they are fully loaded. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
cursor
Bot
changed the base branch from
cursor/ios-composer-dictation-2f83
to
main
August 18, 2026 05:31
cursor
Bot
changed the base branch from
main
to
cursor/ios-composer-dictation-2f83
August 18, 2026 05:31
) * Rebuild context when a bot's engine switches mid-thread Switching a bot's model mid-conversation silently dropped the conversation: the new engine had no session cursor for the thread, wasn't grok, and `rewound` wasn't set — so it received the latest message and nothing else. Switching back was just as broken in a subtler way: the original engine's old cursor was trusted even though another engine had taken turns since. - New server/turn-context.ts: buildTurnContext() extracted from dispatch, with a `fresh` marker distinct from `rewound` (a rewind wipes every instance's cursor; fresh leaves the others alone). - engineIsFresh(): "a different instance ran the last turn on this task", not "this instance has no cursor". Gated on a prior user turn so a new bot's seeded greeting never triggers a replay. Legacy tasks without lastInstanceId fall back to the cursor map, replaying when ambiguous. - TaskRecord.lastInstanceId + store.markTaskDispatched(), set at dispatch (not cursor time — transcript-replay engines never produce a cursor). - e2e in branching.test.ts covers A → B → A via the native protocol tee. Item 1.1 of docs/plans/agent-harness-upgrades-v2.md; both plan documents land with this first PR of the series. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * plan: skip 1.3 (auto-retry) for now, move it to 3.4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * plan: skip 1.6 (image attachments) for now, move it to 3.5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * plan: record the as-built freshness contract; fix doc cross-references Review follow-ups (CodeRabbit on milind-soni#180), docs only: - both plans now describe freshness as dispatch-based (lastInstanceId + engineIsFresh, resumeCursor only when resume) rather than the naive missing-cursor rule that failed A → B → A; v2 Task 1 carries an "as built" note and the interface lists engineIsFresh and markTaskDispatched - v2 Step 8 describes the real per-thread native tee (no per-instance log) - v1 says seventeen changes; v2's coverage list names items 16 and 17 - v1 gains the Upstream references section both documents pointed at Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: milind-soni <milindsoni201@gmail.com>
* Add a per-thread raw event inspector When a bot misbehaves the chat view can't say why; the answer is in two logs the harness already writes per thread — the normalized RuntimeEvent stream (events/) and the provider's verbatim protocol tee (native/) — and until now the only way to read them was by hand. - server/thread-events.ts + GET /api/threads/:id/events?limit=: reads both logs, caps each on its own (the native tee is several times chattier), merges by time, tags kind. 404 for unknown threads; refuses path-shaped ids; a torn line is skipped, not fatal. - InspectorPanel in the chat's right slot (bug icon in the header, same exclusive-panel pattern as Computer/Settings). Events lens: turns, tools, requests, token usage, errors, with runs of content.delta folded into one row; follows live over its own SSE subscription and re-reads the disk when a turn settles. Raw lens: the native tee with in/out direction and per-driver labels. Any row expands to full JSON. - src/lib/inspector.ts keeps the summarizing/folding pure and tested. Item 1.2 of docs/plans/agent-harness-upgrades-v2.md. Nothing new is captured; this only reads back what was already on disk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Validate inspector log records at the wire boundary --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: milind-soni <milindsoni201@gmail.com>
* feat(ios): streamline companion onboarding * feat(ios): add secure QR companion pairing
* feat(ios): open live cloud desktops securely * harden cloud desktop authorization * test companion permission rollback
…-soni#221) The harness server reads `OMB_PORT || OGB_PORT || 8799` (server/index.ts:80), but the dev proxy only read the legacy `OGB_PORT`. Setting `OMB_PORT` — the documented name — moved the server without moving the proxy, so `pnpm dev` silently kept talking to 8799 and every /api call 404'd. This matters for running a second instance beside the first: `OMB_DATA_DIR` isolates the fleet and Electron already probes 8799/18799/28799 (electron/main.mjs:173), but the dev UI could not follow. The vite dev port was hardcoded too, so two `pnpm dev` processes collided on 5199. - proxy target now reads `OMB_PORT` first, keeping `OGB_PORT` as fallback - dev server port reads `OMB_UI_PORT`, defaulting to 5199 Both stay fully backward compatible: unset variables reproduce the previous behavior exactly. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Bring in QR pairing, cloud desktop access, connector auth, and later main commits. Keep inbox attach and dictation alongside those. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Bump a client generation on pair and sign-out so an in-flight inbox upload cannot be sent through a later computer. Bound the inbox descriptor read to the size fstat already allowed. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
* fix(claude): reject colliding ask ids in the permission broker pending is server-scoped, not per-connection: two asks arriving with the same id would let the second pending.set silently overwrite the first, orphaning it once the first resolved and deleted the shared key. Guard at ingestion, before onAsk fires, and deny the collision on the wire instead. * refactor(claude): dedupe socket-connect/answer-read helpers in duplicate-ask-id tests * fix(claude): address CodeRabbit review on PR milind-soni#230 - Escape the client-controlled askId before logging it in the duplicate-ask-id guard, so a crafted id with newlines/control characters can't corrupt log output. - Strengthen the collision tests to assert the full denial payload (id + message) and prove no second request.opened fires, not just behavior: "deny". - Add a duplicate-ask-id collision test for question-kind asks, since the existing coverage only exercised the default permission kind. * test(claude): retry transient Windows pipe connects --------- Co-authored-by: SomSamantray <> Co-authored-by: milind-soni <milindsoni201@gmail.com>
Two multi-interface bugs made discovery invisible and pairing QRs wrong on Macs with a VPN, VM bridge, or Thunderbolt link up: - mdns.ts joined the multicast group per interface but never pinned the send side, so announcements, group answers, and goodbyes left on whichever single interface the kernel routed 224.0.0.251 to — often a utun the phone is not on. Every group send now runs through one serialized queue that calls setMulticastInterface per advertised address before each send (serialized because the pin redirects every subsequent send on the socket), skipping interfaces that vanished between enumeration and send. Unicast answers still route normally. - lanAddresses() returned networkInterfaces() in enumeration order, and the pairing QR embeds the first non-tailnet entry — which could be bridge100 or vmnet. Addresses are now ranked: en0/en1/... first, unrecognized real interfaces next, tunnel/bridge/mesh names (utun, tun, tap, bridge, vmnet, awdl, llw, feth) kept but last. The responder gains a structural ResponderSocket seam so the pinning contract is asserted against a recording socket in tests; CI cannot route the real group. Ranking comparator mutation-checked. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…alth (milind-soni#236) Three lifecycle bugs, all ending the same way: the phone cannot connect and nothing says why. - The sidecar never survived a restart. Only the Settings toggle started it, so a reboot left port 8810 dead until the user found the switch again. The toggle's position now persists in userData (companion-settings.json, the cua-connection.json idiom) and app-ready starts the sidecar with the same options the IPC handler uses — one attempt, failures surface in the panel. Only a start that worked is remembered; stop always clears the flag. - Dev required `pnpm build:companion` that nobody runs. The entry ladder now falls back to companion/src/index.ts with --experimental-strip-types, the way the `companion` script already runs it, and the decision is a pure function (companion-entry.mjs) with tests. Compiled output still wins when it exists; a checkout with neither gets a sentence, not a spawn error. - Advertising was built once at startup: a laptop opened before wifi associates silently never advertised, and DHCP moves left stale A records pointing phones at dead addresses. An address watcher polls the interface table every 5s, re-advertises on any change to the set, withdraws the record when the network goes away, and logs every transition — so discovery.advertising stays a true statement. Also: GET /api/health no longer needs a token. The allowlist's own comment calls it the unauthenticated smoke test, but the auth check ran first and 401'd exactly the person it was for. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…lind-soni#237) Pairing stored one host, and one host is one point of failure: a phone paired over the tailnet keeps a MagicDNS name that stops resolving the moment either device leaves the tailnet — NSURLError -1003, forever — while the same computer sits reachable on the LAN right there. Three pieces, all additive on the wire: - Pairing now hands the phone an ordered candidate list. The sidecar computes [MagicDNS name, LAN addresses, its own mDNS name last] in hostCandidates(); the list rides the QR link as a `hosts` param and the /api/pair redeem response as a `hosts` field. The single-host `address` field stays, older phones ignore the new one, and a saved Connection without `hosts` still decodes. - Late binding on the phone. CandidateRotation (pure, in CompanionCore) walks the list when a stream fails with an address-shaped URLError (-1003/-1004/-1001/-1200) and promotes — and persists — whichever candidate carries a live stream, so the next launch dials the working address first. A 401 never rotates: that is a token problem, and hiding it behind an address walk would mask the real fix. - Errors that say what to do. ConnectionAdvice maps the URLError codes to advice (-1003 names the tailnet possibility, -1004 points at the Companion toggle, -1001 blames the route, -1009 says offline), names the candidate being tried next, and always says the app keeps retrying. Settings additionally gains an Edit address affordance that replaces the host while keeping the pairing and its token. Validated: swift test 106 green (rotation logic mutation-checked both ways), simulator build green, vitest 997 green, typecheck and oxlint clean on every touched file. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Use an explicit localhost image reference for the locally built Cua VM so Podman does not resolve it through Docker Hub. Adds regression coverage for issue milind-soni#231.
Windows CI has been red since the ask-id collision tests landed: their thread ids (t-perm-dup-1, t-perm-dup-2, …) agree on the first eight characters, which was the whole socket tag — so on Windows they shared one named pipe. POSIX hides the collision because a new broker's listen replaces the socket FILE and the name always points at the fresh server, but the Windows pipe namespace is global and never unlinked, so a reused name races the previous broker's async teardown. That race is why the same tests pass on macOS and Linux and fail on windows-latest. The tag is now four readable chars + four hex chars of a sha256 of the FULL thread id, so distinct threads can never share a socket. The tag stays at eight chars total because the POSIX socket path already brushes the 104-byte sun_path limit under the deep tmp home dirs the tests use — a longer tag fails listen(2) with EINVAL on macOS. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Companion keepalive, LAN address ranking, and later main commits. Keep inbox attach and dictation alongside those. Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
The iOS chat composer can send a photo or a file. Plus menu: Attach Image, Take Photo, Choose File. Chips sit above the field. Send with or without a caption.
The harness has no upload route. Desktop attachments are already-on-disk paths (
<attached-file path="…" />in the JSON{ text }). A photo on the phone is not on that disk, so:POSTs the bytes to the sidecar (/api/inbox) withX-OpenMaus-Filename.~/.openmausbot-companion/inbox/(8 MB ceiling, sanitised names,0700dir /0600file) and returns{ path, name, size }.<attached-file path="…">tag the desktop composer already uses. The agent opens the file on the Mac.GET /api/inbox/:nameis basename-only, allowlisted, authenticated, and refuses a symlink, a directory, a traversal, or a body over the ceiling — so the bubble can show the photo without turning a stolen token into a reader for the rest of the disk. Bytes the phone just uploaded are also cached locally, so the thumbnail does not depend on that round-trip.A failed send keeps the chips (
hostis set after a successful write) so retry does not re-upload. Eight files, 8 MB each. A simulator has no camera and says so rather than crashing.NSCameraUsageDescriptionis inios/project.yml.Also in this branch, because attach made them visible:
remark-gfm). Wide tables scroll sideways.App/is an Xcode 16 synced folder (projectFormat: xcode16_0, XcodeGen 2.44+) so a pull no longer leaves Xcode looking for Swift files the pbxproj remembered and the disk does not.This is the fork stacked PR (attach-only vs dictation). Open the upstream PR against
milind-soni/OpenMausBotmainthe same way as #210:https://github.com/milind-soni/OpenMausBot/compare/main...mnthr7:OpenMausMobile:cursor/ios-composer-attach-2f83?expand=1
Until milind-soni#210 merges, that upstream diff includes dictation; the attach-only work starts at
9e57267.Follows milind-soni#161 / milind-soni#204 / milind-soni#210. No harness (
server/) change — the sidecar is the only new surface.Why
A phone cannot hand the agent a host path it does not have. Writing the file onto the computer through the sidecar keeps every driver on the tagged-path shape they already understand, and does not widen the harness API.
The bubble has to show the photo, not the Mac path tag. That tag is for the agent. The roster line is the original filename, not
1787…-photo.jpg.How it was verified
pnpm exec vitest run companion/test/inbox.test.ts companion/test/routes.test.ts companion/test/proxy.test.ts— 53 passed (Node 24). Includes GET-after-POST, 401 without a token, 413 over the ceiling, traversal names, symlink / directory / oversized GET refusals.pnpm exec tsc -p tsconfig.companion.build.json— clean.ios/Tests/CompanionCoreTests/AttachmentTests.swift. GFM tables:MarkdownTests.swift.ios/TESTING.mdstage 4 step 7): Attach Image, thumbnail in the user bubble (not a path chip), GFM table in the bot reply, wrapping composer stays a rounded rectangle.cd ios && xcodegen generate, then open the project. Electron companion servesdist-companion, sopnpm build:companionand Companion off/on is required forGET /api/inbox— an iOS rebuild alone is not enough.swift testneeds a Mac; this environment has no Swift toolchain.Screenshots (UI changes)
Composer plus menu on the left of the field (Attach Image / Take Photo / Choose File). A sent photo shows as a thumbnail in the user bubble, not as a host path. Bot replies with GFM tables render as a table.
Please drop in the panel-directory screenshot (electrical-panel photo in the user bubble, transcribed table in the bot bubble).
Checklist
pnpm typecheckandpnpm test(full suite) —server//src//electron/are unchanged by this diff; companion tests above were runserver/is unchangeddist-server/edits (it's build output)shell: true/ cmd.exe string-building — iOS App target + Foundation-only CompanionCore + Node sidecar