接入汪汪声浪结果页原生图片导入

汪汪声浪结果页三图槽位上传优先复用file.importImage宿主能力

补充原生导入取消和浏览器回退测试

将汪汪声浪结果页纳入原生壳H5调用链门禁

更新宿主壳协议、方案文档和共享记忆
This commit is contained in:
2026-06-19 10:13:26 +08:00
parent bda650e598
commit 4a16263729
7 changed files with 244 additions and 10 deletions
@@ -2557,6 +2557,13 @@
- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx``src/services/host-bridge/hostBridge.ts`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-19 汪汪声浪结果页三图槽位复用原生图片导入
- 背景:汪汪声浪结果页的玩家形象、对手形象和 UI 背景槽位已经支持浏览器文件输入、单槽上传、单槽重生成、试玩和发布,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 系统选择器体验。
- 决策:`BarkBattleResultView` 的三图槽位上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的 base64 图片副本转换成浏览器 `File` 后继续交给 `uploadBarkBattleAsset` 上传和当前槽位写回链路。用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
- 影响范围:`src/components/bark-battle-creation/BarkBattleResultView.tsx``scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- src/components/bark-battle-creation/BarkBattleResultView.test.tsx``npm run typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-18 方洞结果页图片槽位接入原生壳图片导入
- 背景:方洞结果页的封面、背景、形状和洞口图片槽位已经支持浏览器文件输入、历史图选择、AI 生成和自动保存,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 能力。
@@ -219,6 +219,7 @@ npm run check:native-shells
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。
创作 Agent 参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给既有 `onReferenceImageChange` 校验链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。
该命令同时会运行微信小程序 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。
该命令还会反查微信小程序 `app.json.pages``host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。
File diff suppressed because one or more lines are too long
@@ -71,7 +71,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPickerTauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain``text/markdown``text/csv``application/json``application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径。
- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台的参考图上传、反馈页上传凭证、个人资料头像上传方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台的参考图上传、反馈页上传凭证、个人资料头像上传方洞结果页图片槽位上传和汪汪声浪结果页三图槽位上传`native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。
HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。`npm run check:native-shells` 会反查共享事件清单、H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,防止后续事件订阅绕过双能力门控。
+2
View File
@@ -66,6 +66,7 @@ const h5HostBridgeCallChainWrapperFiles = [
const h5HostBridgeRequiredCallChainFiles = [
'src/App.tsx',
'src/components/auth/AuthGate.tsx',
'src/components/bark-battle-creation/BarkBattleResultView.tsx',
'src/components/common/CreativeAudioInputPanel.tsx',
'src/components/common/CreativeImageInputPanel.tsx',
'src/components/common/PublishShareModal.tsx',
@@ -324,6 +325,7 @@ const h5HostBridgeTests = [
'src/services/host-bridge/hostBridge.test.ts',
'src/services/host-bridge/nativeAppHostBridge.test.ts',
'src/App.test.tsx',
'src/components/bark-battle-creation/BarkBattleResultView.test.tsx',
'src/components/common/CreativeAudioInputPanel.test.tsx',
'src/components/common/PublishShareModal.test.tsx',
'src/components/creation-agent/CreationAgentWorkspace.test.tsx',
@@ -2,12 +2,16 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { BarkBattleResultView } from './BarkBattleResultView';
vi.mock('../../services/bark-battle-creation', () => ({
@@ -15,6 +19,11 @@ vi.mock('../../services/bark-battle-creation', () => ({
uploadBarkBattleAsset: vi.fn(),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(),
importHostImageFile: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
@@ -41,6 +50,13 @@ const draft = {
};
describe('BarkBattleResultView', () => {
beforeEach(() => {
vi.mocked(uploadBarkBattleAsset).mockReset();
vi.mocked(regenerateBarkBattleImageAsset).mockReset();
vi.mocked(canUseNativeHostCapability).mockReset();
vi.mocked(importHostImageFile).mockReset();
});
it('exposes draft preview actions before publish', async () => {
const user = userEvent.setup();
const onStartTestRun = vi.fn();
@@ -168,6 +184,153 @@ describe('BarkBattleResultView', () => {
);
});
it('imports replacement image assets through native HostBridge', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const onDraftChange = vi.fn();
vi.mocked(canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(importHostImageFile).mockResolvedValue({
action: 'selected',
fileName: '玩家形象.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
});
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
assetObjectId: 'asset-player-1',
assetKind: 'bark_battle_player_character_image',
objectKey: 'generated-bark-battle-assets/player.png',
assetSrc: '/generated-bark-battle-assets/player.png',
});
try {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', {
name: '上传',
}),
);
await waitFor(() => {
expect(uploadBarkBattleAsset).toHaveBeenCalledWith(
expect.objectContaining({
slot: 'player-character',
draftId: 'bark-battle-draft-1',
file: expect.any(File),
}),
);
});
const uploadedFile = vi.mocked(uploadBarkBattleAsset).mock.calls[0]?.[0]
.file;
expect(importHostImageFile).toHaveBeenCalledTimes(1);
expect(uploadedFile?.name).toBe('玩家形象.png');
expect(uploadedFile?.type).toBe('image/png');
expect(uploadedFile?.size).toBe(5);
expect(inputClickSpy).not.toHaveBeenCalled();
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
} finally {
inputClickSpy.mockRestore();
}
});
it('keeps native image cancellation inside shell flow', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.mocked(canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(importHostImageFile).mockResolvedValue(false);
try {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', {
name: '上传',
}),
);
await waitFor(() => {
expect(importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(uploadBarkBattleAsset).not.toHaveBeenCalled();
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
it('falls back to browser picker without native image capability', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
try {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', {
name: '上传',
}),
);
expect(inputClickSpy).toHaveBeenCalledTimes(1);
expect(importHostImageFile).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
it('does not render the raw object key or asset path in the slot summary', () => {
render(
<BarkBattleResultView
@@ -24,6 +24,10 @@ import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -103,6 +107,20 @@ function getSlotAssetSrc(
return '';
}
function base64ImageToFile(
base64Data: string,
fileName: string,
mimeType: string,
) {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mimeType });
}
function ResultActionButton({
children,
disabled,
@@ -140,15 +158,14 @@ function BarkBattleAssetSlotControl({
onError: (message: string | null) => void;
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isImportingImage, setIsImportingImage] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot);
const assetStatus = assetSrc ? '已替换' : '未替换';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
const uploadFile = async (file: File) => {
if (isUploading || isRegenerating) {
return;
}
@@ -169,6 +186,49 @@ function BarkBattleAssetSlotControl({
}
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
await uploadFile(file);
};
const openUploadPicker = () => {
if (disabled || isImportingImage || isUploading || isRegenerating) {
return;
}
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
setIsImportingImage(true);
try {
const importedImage = await importHostImageFile();
if (!importedImage) {
return;
}
await uploadFile(
base64ImageToFile(
importedImage.base64Data,
importedImage.fileName,
importedImage.mimeType,
),
);
} finally {
setIsImportingImage(false);
}
})().catch((error) => {
onError(error instanceof Error ? error.message : '上传素材失败。');
});
return;
}
fileInputRef.current?.click();
};
const handleRegenerate = async () => {
setIsRegenerating(true);
onError(null);
@@ -187,7 +247,7 @@ function BarkBattleAssetSlotControl({
}
};
const isSlotBusy = isUploading || isRegenerating;
const isSlotBusy = isImportingImage || isUploading || isRegenerating;
return (
<PlatformSubpanel
@@ -218,12 +278,13 @@ function BarkBattleAssetSlotControl({
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
disabled={disabled || isSlotBusy}
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
/>
<PlatformActionButton
disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()}
onClick={openUploadPicker}
tone="secondary"
size="xs"
shape="pill"