Skip to content

feat(console): take photos, open attachments and share records through the mobile shell - #29803

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-6oh3sk
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-6oh3sk

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

domain:ui#3 · zhuangjianguo · session_01QsR2jV4dWohojjbTJTQq5m

Requested by Steedos · project thread

Fixes #29735

Clause-②: yes
Metadata-protocol: none

Claim: comment 6595193307 (branch claude/project-thread-6oh3sk); file surface amended in 6595245493 (seat ruling: record share only, never pass the signed URL to the shell), plus ui/packages/mobile/README.md on the seat's word.

维护者速读

  • 改了什么:在移动端外壳(ObjectStack mobile)里,文件/图片字段多了「拍照 / 从相册选择」(走外壳的 pickImage()),记录附件点开时由 Console 用自己的会话取回文件字节交给外壳的 openFile() 预览,记录页「分享」走外壳的 share() 调起系统分享面板。普通浏览器和旧版外壳行为完全不变。
  • 为什么改:维护者裁决(mobile#147 选项 A)要求 Console 自己取文件、外壳不碰会话;外壳侧 mobile#180 已提供这三个方法,Console 若不调用就是「已声明却无人使用」的能力。
  • 风险与回滚:风险低。三处都先做能力探测(has()),不满足就走原来的路径;拍到的照片走字段原有的上传管线(同样的大小校验、同样存 sys_file 引用)。附件超过 8 MiB、取字节失败或外壳拒收(bad_args)都回到原来的打开方式。签名下载链接是凭证,从不交给外壳。回滚即 revert 本 PR。
  • 先看:① ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx 的 openInNativeShell(取字节、8 MiB 判断、回退);② ui/packages/fields/src/widgets/FileField.tsx / ImageField.tsx 里照片如何进入原上传管线;③ ui/packages/mobile/src/nativeHost.ts 新增的可选成员与包装函数(公开面扩展,需契约复核)。
  • 两分钟验证:cd ui && pnpm exec vitest run packages/fields/src/widgets/nativeImagePick.shellPhoto.test.tsx packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx packages/mobile/src/__tests__/nativeHost.test.tsx,预期 4 个文件全绿(135 个用例)。
  • 需要你做的:无,席位落地(@object-ui/mobile 公开面扩展的契约复核由席位转总监)。

What changes

The ObjectStack mobile shell offers three optional bridge methods for files (objectstack-ai/mobile docs/HYBRID-SHELL.md, "Files", shell side mobile#180), each feature-detected with has(). The Console now calls them. A plain browser and an older shell take exactly today's path.

  1. Bridge (@object-ui/mobile, nativeHost.ts). ObjectStackNative gains optional pickImage?, openFile? and share? (an older shell lacks them), the method union gains the three names, and the typed wrappers nativePickImage, nativeOpenFile, nativeShare follow the scan / showConnectionSwitcher precedent (feature-detect through nativeHostCan, then host.x!(); resolve unavailable | ok | failed, never throw). New types NativePickImageOptions, NativeFile, NativeShareContent; the bridge limit NATIVE_FILE_MAX_BYTES (8 MiB, the shell's MAX_FILE_BYTES); helpers fileFromNativeFile (bridge file to an uploadable File) and nativeFileFromBlob (bytes to padded base64 with no data: prefix and a parameter-free MIME type). All exported from the package entry: a public widening, so a contract review is owed.
  2. pickImage (@object-ui/fields). In a shell announcing pickImage, FileField (when the field accepts images) routes its existing camera button through pickImage({ camera: true }) and adds "Choose photo" (pickImage({ camera: false })); capture: false still hides the camera. ImageField adds "Take photo" and "Choose photo" under its upload button. The picked image becomes a File and enters the SAME pipeline an input pick does: FileField's processFiles, and ImageField's input handler body lifted into uploadPicked, which both the input and the shell call. No second upload route. Cancel (null) uploads nothing; a shell failure shows the error toast with the shell's message (fields.file.photoFailed). Internal helper widgets/nativeImagePick.ts, not exported. The grid FileCell and AvatarField are not touched.
  3. openFile (@object-ui/app-shell, RecordAttachmentsPanel). The row's Download tap still fetches the signed URL with the Console's own session. In a shell announcing openFile, it then fetches the bytes and hands only the bytes to openFile. A row over 8 MiB (judged before any byte is fetched, and again on the fetched size), a failed byte fetch (for example a cross-origin storage URL without CORS), or any non-ok answer (bad_args included) keeps today's window.open.
  4. share (@object-ui/app-shell, RecordDetailView sys_share). In a shell announcing share, the record header's Share calls share({ url: location.href, text: document.title }). A refusal or failure falls back to today's navigator.share or clipboard copy. Only the record URL crosses the bridge.
  5. i18n. fields.file.choosePhoto and fields.file.photoFailed in the ten locale packs.
  6. Docs. ui/packages/mobile/README.md: the typed-wrapper line names the three new wrappers, and one entry names NATIVE_FILE_MAX_BYTES and the two file helpers (added to the amended file list on the seat's word: doc follows export).

Tests

  • packages/mobile/src/__tests__/nativeHost.test.tsx: the fake bridge carries the three methods; each wrapper resolves unavailable with no bridge, when has() says no, and when the method is missing; pickImage forwards options and resolves the file or null; openFile forwards the file and resolves failed / bad_args on refusal; share forwards url and text; the 8 MiB constant; base64 decode and encode helpers.
  • packages/fields/src/widgets/nativeImagePick.shellPhoto.test.tsx (new, 12 tests): buttons only when has('pickImage'); camera and library both upload the picked File (name, type, size checked) through the field's UploadProvider adapter and onChange gets the sys_file id; cancel uploads nothing; failure toasts and uploads nothing; capture: false; a non-image accept; the picked image meets the same maxSize check. CONTROL: no shell and an older shell draw no shell button.
  • packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx, new top-level describe (6 tests): openFile gets exactly { base64, name, mime }, the signed-URL token appears in no shell call, openExternal / share untouched, no tab; over 8 MiB by row size (no byte fetch) and by fetched size; bad_args; failed byte fetch; CONTROL older shell. The plain-browser control is the existing signed-URL download describe, unchanged and green.
  • packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx, new top-level describe (4 tests): Share calls share with exactly the record URL and title and copies nothing; refusal falls back to the clipboard; CONTROL older shell and plain browser copy as today.
  • Ablation per leg, each restored to HEAD by scripts/ablation-replace.mjs (blob == HEAD, git diff HEAD empty): bridge share forwards {} (1 red); FileField drops the picked files (2 red); ImageField drops the picked files (2 red); the panel skips openInNativeShell (2 red); Share ignores the shell's ok (1 red).

Acceptance notes

  • Attachment share is not added (seat ruling A in 6595245493): no attachment share action exists, the contract's Console list names only "sharing a record link", the stable /storage/files/:id link needs the Console's Bearer session (dead for a recipient), and the signed URL is a credential.
  • plugin-detail's DetailView has its own sys_share; it is not on the Console record page and is unchanged.
  • The file-field download links (FileValueAffordance), the grid FileCell and AvatarField keep today's behaviour; the contract names attachments and image/attachment fields.
  • openFile passes the row's file name as is; a name the shell refuses (bad_args) takes the browser path.
  • No real shell in the container: every leg is driven through the shell fake.

Verification

Final head 09666e32: merged origin/main b62463f after PR #29798 landed the app-lock bridge members in the same files. The conflicts in nativeHost.ts (method union, interface members, wrappers), index.ts, the nativeHost.test.tsx fake and ui/packages/mobile/README.md were resolved as a union. Both sides' members, wrappers, exports and keys are kept, and none is dropped. en.ts merged on its own. After the merge, the @object-ui/app-shell closure was rebuilt together with formula, sdui-parser, lint, client and client-react (66 tasks, exit 0).

  • type-check at 09666e32: turbo run type-check for @object-ui/mobile, @object-ui/fields, @object-ui/app-shell and @object-ui/i18n: 68/68 tasks, exit 0.
  • Suites at 09666e32 (locked, 2 workers): the native-shell set (git grep -l of @object-ui/mobile or ObjectStackNative in tests, which now includes the app-lock tests), every test naming FileField, ImageField or RecordAttachmentsPanel, the four new or touched test files, and packages/i18n/: 150 files, 2825 passed, 13 skipped, 0 failed.
  • The other 99 RecordDetailView*.test.tsx at 3d7a69ca: 99 files, 619 tests green. Neither main merge touched RecordDetailView's share entry.
  • eslint on the 11 touched source and test files: 0 errors (at 3d7a69ca).
  • Derived gates at 09666e32: dispatch-gates --commands derived 35, all 35 ran, and all exited 0 (this includes pnpm --dir ui exec vitest run scripts/, check:type-check-debt, check:published-files, check:nul-bytes, check-sdui-manifest, check-changeset-no-major, check-adr-0087-registration, and check-closing-keyword-parity against this body). dispatch-gates --ran: 35 run, 0 NOT-MEASURED. The same 35 also exited 0 at b37e3124.
  • Contract review PASS (comment 6596376341) was given at b37e3124. This merge adds no line to this PR's own diff.
  • Ablations: listed under Tests, run at c413ef3c.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract-review-claim: session_01QsR2jV5sBSzCYNEDgpYLJw · head b37e312 · 2026-11-03T05:40Z


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b37e3124468c2ed573458c468586502b753173e3
Local-runs: none
Scope: every diff path — .changeset/29735-mobile-shell-files.md; ui/packages/mobile/src/nativeHost.ts, mobile/src/index.ts, mobile/src/__tests__/nativeHost.test.tsx, mobile/README.md; ui/packages/fields/src/widgets/FileField.tsx, widgets/ImageField.tsx, new widgets/nativeImagePick.ts, new widgets/nativeImagePick.shellPhoto.test.tsx; ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx, views/__tests__/RecordAttachmentsPanel.test.tsx, views/RecordDetailView.tsx, views/RecordDetailView.favoritesEntry-12427.test.tsx; ui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts — read against main: mobile/src/nativeHost.ts L83–136 (NativeScanOptions, the method union, the optional scan? precedent), L186–216 (nativeHostCan, callNative), L244–256 (nativeScan / nativeShowConnectionSwitcher), mobile/src/index.ts L66–100, fields/package.json L36 and app-shell/package.json L86 (both already depend on @object-ui/mobile; ScanFieldButton.tsx already imports it), RecordAttachmentsPanel.tsx L47–55 (AttachmentRow), every NativeHostMethod consumer outside the mobile package (none), card #29735, claim 6595193307, amendment 6595245493, and os-dev report 6596330475. The shell contract (objectstack-ai/mobile docs/HYBRID-SHELL.md "Files", mobile#180) was read as the card quotes it; that repository is outside this session's scope.

Read-only; nothing built or run. Card #29735 (mobile#147 ruling letter A: the Console fetches files with its own session; the shell never touches the session).

① Derived judgments

  • The widening is exactly what the claim, the changeset and the report name, and only on @object-ui/mobile and @object-ui/i18n. The mobile barrel adds six values (nativePickImage, nativeOpenFile, nativeShare, fileFromNativeFile, nativeFileFromBlob, NATIVE_FILE_MAX_BYTES) and three types (NativePickImageOptions, NativeFile, NativeShareContent); ObjectStackNative gains three optional members and NativeHostMethod three names. Nothing leaves. Optional members and a union that only grows are additive for every existing implementer and caller: no code outside the mobile package switches on NativeHostMethod, and the test fake is the one implementer in the tree. fields and app-shell already depend on @object-ui/mobile, so no dependency edge is added. nativeImagePick.ts is not exported from the fields entry. The ten packs gain fields.file.choosePhoto and fields.file.photoFailed; ImageField's "Take photo" reuses the existing fields.file.takePhoto.
  • The wrappers follow the scan precedent exactly: callNative feature-detects through nativeHostCan (has(method) === true and the member is a function) and resolves unavailable | ok | failed; the bridge types match the card's contract (pickImage(opts?) → NativeFile | null, openFile(NativeFile) → null, share({ url?, text? }) → null, bad_args on an oversized file, 8 MiB).
  • Letter A holds at every call site. openInNativeShell runs after the panel has fetched the signed URL with the Console's session as before; it fetches the bytes from that URL and hands the shell { base64, name, mime } only. The size is judged on row.size before any byte moves and on blob.size after; a non-ok response, a thrown fetch (cross-origin storage without CORS), or any non-ok wrapper answer returns false and the existing window.open runs. sys_share hands the shell location.href and document.title; the Bearer session and the signed URL never cross the bridge, and the new panel tests assert the signed token appears in no shell call.
  • One upload pipeline. FileField routes the picked File into its existing processFiles (same maxSize, same sys_file write); ImageField lifts its input handler body into uploadPicked, which both the input and the shell picker call. The hooks (useNativeImagePickOffered) sit before ImageField's readonly early return, so hook order is stable.

② Semver level

  • Four packages patch, Clause-②: yes (bare = widening), Metadata-protocol: none → patch is the level the protocol axis gives. No ADR-0087 marker is needed: nothing retires.

③ Boundary flags

④ Design judgment

  • Feature-detect, then fall back to exactly today's path, is the right shape for an optional shell capability, and callNative resolving unavailable within the same task keeps the navigator.share fallback's user activation in a plain browser.
  • Judging the 8 MiB cap on the row before fetching, and never encoding a file the shell would refuse, is the right reading of the limit; falling back on bad_args rather than surfacing it means a tap is never answered worse than before.
  • Keeping the signed URL out of the share sheet (option A) is correct: it is a bearer-free credential, and the stable link is dead for a recipient without the session.
  • Narrowing face: nothing is taken away; the <input type=file> camera path stays for capture: false and for every host without pickImage.

No must-fix. No should-fix.

Implemented-by: claude/project-thread-6oh3sk
Reviewed-by: session_01QsR2jV5sBSzCYNEDgpYLJw

VERDICT: PASS


Generated by Claude Code

…-6oh3sk

Keeps both sides of the bridge: the app-lock members and wrappers from main
beside pickImage, openFile and share.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSqiKZMCzGXQK5xzUq

Copy link
Copy Markdown
Collaborator

Contract-review-claim: session_01QsR2jV5sBSzCYNEDgpYLJw · head 09666e3 · 2026-11-03T06:17Z


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 09666e3299b59d04297094529788bfc2cecf0844
Local-runs: none
Scope: the delta b37e3124…09666e32 — one merge commit (parents b37e3124 and main b62463f1; compare API: 16 ahead, 0 behind) resolving PR #29798 (#29748) on the four files both PRs edit, ui/packages/mobile/src/nativeHost.ts, mobile/src/index.ts, mobile/src/__tests__/nativeHost.test.tsx and ui/packages/i18n/src/locales/en.ts, plus mobile/README.md — read three ways: the merge against its first parent restricted to this PR's 23 files (what the merge changed on the PR's side), the PR's diff against main at this head (23 files, +873/−18), and the head's nativeHost.ts and index.ts in full; and record 6596376341 (the review of b37e3124, whose ①, ② and ④ carry over).

Re-review for a conflict resolution, not a regen. Read-only; nothing built or run.

① Derived judgments

② Semver level

  • Unchanged: four packages patch, Clause-②: yes, Metadata-protocol: none.

③ Boundary flags

  • CI on 09666e32: 44 success, 11 skipped, 0 failure; mergeable_state: clean. The overlap flagged in record 6596376341 is resolved by this merge; no further open PR edits these files that I can see.

④ Design judgment

  • Unchanged from record 6596376341.

No must-fix. No should-fix.

Implemented-by: claude/project-thread-6oh3sk
Reviewed-by: session_01QsR2jV5sBSzCYNEDgpYLJw

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#3 · zhuangjianguo · session_01QsR2jV4dWohojjbTJTQq5m

席位核对 · ACCEPT · 2026-11-03T06:17Z

Head-sha: 09666e3299b59d04297094529788bfc2cecf0844

  • Shape: draft → main, and Fixes #29735 is the only closing keyword in the body. 23 files, +873 / −18. check-governed-merges --pr 29803 reports NOT governed, at 886 lines, so this is an ordinary queue landing. Clause-②: yes and Metadata-protocol: none are line-initial in the body and the changeset, as in claim 6595193307 (amended 6595245493). The changeset patches @object-ui/mobile, fields, app-shell and i18n.
  • Contract review: PASS at this head, record 6596881196 (the re-review after the main merge). The first PASS was 6596376341 at b37e3124. Neither has a must-fix or a should-fix.
  • File surface: inside the amended claim, plus ui/packages/mobile/README.md on the seat's word (doc follows export). The merge with feat(console): App lock row on the profile page inside the mobile shell #29798 (b62463f1) is a union. Against b62463f1, the contract files only add lines. The one rewritten README sentence names both feat(console): App lock row on the profile page inside the mobile shell #29798's App lock wrappers and this PR's three.
  • Read in the diff:
    • openInNativeShell fetches the signed URL the panel already fetched, and hands only { base64, name, mime } to openFile. It judges 8 MiB on the row size before any fetch and again on the blob. A failed fetch, a non-ok answer or bad_args keeps window.open.
    • Record Share sends only { url: location.href, text: document.title }. callNative resolves unavailable in the same task, so the navigator.share fallback keeps its user activation.
    • Both fields route the picked File into their one existing pipeline: processFiles, and uploadPicked lifted from the input handler. capture: false still hides the camera.
    • fields and app-shell already depend on @object-ui/mobile. fields.file.takePhoto already existed. The two new pack keys are choosePhoto and photoFailed.
  • Tests (dev, report 6596330475): 4 new or touched files, 135 pass. An ablation per leg goes red (5), each restored clean. The sweep is 153 files with 2831 passing and 0 failing. The 99 RecordDetailView files are green. Type-check passes 68/68. Of 35 derived gates, all 35 exit 0, with 0 NOT-MEASURED.
  • Gates (check-runs on this head): 44 success, 11 skipped (opt-in and schedule gates), 0 failed, 0 pending. Lint & Repo Gates and TypeScript Type Check both succeeded. mergeable_state is clean.
  • Accepted deviations: there is no real shell in the container, so every leg runs through the bridge fake and no phone shots exist. The real-device check of the three methods rides on the shell side (feat(bridge): pickImage, share and openFile with bytes mobile#180).
  • Out of scope: Acceptance notes only (attachment share is ruled out by A in 6595245493; the plugin-detail DetailView share, FileValueAffordance, FileCell and AvatarField are unchanged). Nothing to file.
  • Landing: pr_ready, then automerge_enable. Merging closes Console: use the mobile shell's pickImage(), openFile() and share() for photo capture, attachments and sharing #29735.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 06:18
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 06:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit a7e6e07 Nov 3, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-6oh3sk branch November 3, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console: use the mobile shell's pickImage(), openFile() and share() for photo capture, attachments and sharing

2 participants