From 3cb8cf4ee2c4e27bca8cb418f8335c48cb27c59b Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:11:22 -0400 Subject: [PATCH 01/11] docs: design spec for attachments + local TestFlight build Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- ...026-06-12-attachments-testflight-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-attachments-testflight-design.md diff --git a/docs/superpowers/specs/2026-06-12-attachments-testflight-design.md b/docs/superpowers/specs/2026-06-12-attachments-testflight-design.md new file mode 100644 index 0000000000..c7eb1d139a --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-attachments-testflight-design.md @@ -0,0 +1,114 @@ +# Design: Photo/File Attachments + Local TestFlight Build + +Date: 2026-06-12 +Status: Approved (brainstorm complete) + +## Goal + +1. Add attachment support (photos, camera, arbitrary files/PDFs) to the Happy app, full scope of issue #1319 (also covers #1270, #919, #70), using upstream PR #554's architecture as a blueprint but implemented fresh on current main. +2. Ship a personal iOS build to TestFlight via local headless xcodebuild (App Store Connect API key signing — Seneca/SoundSpotter pattern), incorporating our open PRs #1372 (Fable 5) and #1373 (per-model effort + Opus 4.8[1m]) without waiting for upstream merge. + +## Branch strategy + +``` +upstream/main + ├─ feat/fable-5-model (PR #1372, exists) + ├─ feat/claude-model-effort (PR #1373, exists, stacked on fable-5) + ├─ feat/attachments (NEW — clean off main; app + cli changes; PR upstream) + └─ local/testflight (NEW — integration branch: main + all 3 feature branches + + one local-only commit: bundle ID, build script. + Never PRed upstream.) +``` + +- TestFlight config (bundle ID swap, build script) lives only on `local/testflight`. +- Integration branch is rebuilt whenever upstream merges a PR; merged feature branches drop out naturally. +- happy-cli changes run from local dist via the existing daemon setup; the CLI is not part of the TestFlight artifact. + +## Attachment feature + +### Architecture (from PR #554, extended) + +``` +App picks attachment → (images: normalize) → chunked upload via RPC to CLI + machine's $TMPDIR/happy/uploads/{sessionId}/ → message text gains + [image: /path] / [file: /path] refs → agent reads files with Read tool +``` + +Zero server/protocol changes. Reuses existing encrypted RPC channel. + +### App side (`packages/happy-app/sources`) + +| Unit | Purpose | +|------|---------| +| `utils/attachments.ts` (native) | Gallery + camera via `expo-image-picker`; files via `expo-document-picker`. | +| `utils/attachments.web.ts` | File picker + clipboard paste (Canvas-based image normalize). Follows existing `.web` split pattern. | +| `utils/attachments.shared.ts` | Base64 validation, chunked RPC upload, upload-dir caching, filename sanitization. | +| `hooks/useAttachments.ts` | Pending-attachment state, max 5, pick/capture/remove handlers. | + +**Image handling (quality-preserving):** +- Downscale only if long edge > 1568 px (Claude vision API ceiling — API downscales beyond this itself; larger is pure waste). +- HEIC → JPEG conversion mandatory (vision API does not accept HEIC), JPEG quality 0.9. +- No aggressive 520 KB squeeze — images use the same chunked upload path as files. + +**Files:** raw bytes, no transformation. 5 MB cap, error toast beyond. + +**UI (`AgentInput.tsx`):** +- “+” button in action bar → action sheet: Photo Library / Camera / Choose File. Count badge; disabled at 5. +- Chips strip above input: thumbnail (images) or file icon + name, per-chip remove. +- On send: upload all pending, then append `[image: /path]` / `[file: /path]` refs to the message text. + +**Permissions:** `NSCameraUsageDescription`, `NSPhotoLibraryUsageDescription` via `app.config.js`. + +**i18n:** new keys in `text/_default.ts` + all 11 translation files. + +### CLI side (`packages/happy-cli`) + +- `getUploadDir` RPC → returns `$TMPDIR/happy/uploads/{sessionId}/` (created on demand). +- `pathSecurity.validatePath` gains `additionalAllowedDirs` covering the upload dir. +- **Chunked upload:** new `appendFile` RPC (or offset parameter on `writeFile`); app loops 256 KB chunks. Socket.io payload limit is 1 MB, so single-shot writes cap at ~520 KB base64 — chunking lifts that for both large images and files. +- Strip base64 image data from tool results before socket transport (agent Read of an image file otherwise overflows the socket) — ported from #554. +- System prompt addition instructing the agent to use Read on `[image:]` / `[file:]` refs. + +### Testing + +- pathSecurity traversal tests (upload dir allowlist). +- Chunked-write reassembly test (CLI). +- `useAttachments` hook tests (max-5, remove, send-clears). +- Image normalize tests (downscale threshold, HEIC conversion path where mockable). + +## TestFlight build (local/testflight only) + +### One-time manual prereqs (public ASC API cannot create app records) + +1. Register bundle ID `ca.lixfeld.happy` in the Apple Developer portal. +2. Create the ASC app record against it (name e.g. “Happy JL”). TestFlight-only; never App Store. + +### `scripts/build-ios-testflight.sh` + +``` +fetch APPLE_ASC_KEY_ID / APPLE_ASC_ISSUER_ID (Infisical) +verify ~/.appstoreconnect/private_keys/AuthKey_.p8 exists — fail fast +APP_ENV=production expo prebuild (regenerates ios/) +xcodebuild archive -allowProvisioningUpdates -authenticationKeyPath/-KeyID/-KeyIssuerID +xcodebuild -exportArchive (app-store method) + same three auth flags +xcrun altool --upload-app --apiKey/--apiIssuer +``` + +Both `archive` and `-exportArchive` carry the three `-authenticationKey*` flags — without them, headless automatic signing fails (`error: No Accounts`) or produces a generic profile missing entitlements. + +### Local-only commit contents + +- `app.config.js`: bundle ID → `ca.lixfeld.happy`, display-name tweak. +- `scripts/build-ios-testflight.sh`. +- Timestamp-based build-number auto-increment. + +### Known limitation + +Push notifications do not work in the fork build: happy-server sends APNs pushes with slopus’ credentials, which are tied to their bundle ID. Pairing and E2E sync against api.happy-servers.com are unaffected (bundle ID is irrelevant to the backend protocol). + +## Out of scope + +- Server changes of any kind. +- Android build/distribution. +- App Store (non-TestFlight) release. +- Restoring push notifications in the fork build. From 5d2e3e7f1615e02d7eab47d0e53392986b46550c Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:21:26 -0400 Subject: [PATCH 02/11] =?UTF-8?q?docs:=20rev=202=20spec=20=E2=80=94=20matc?= =?UTF-8?q?h=20existing=20upstream=20attachment=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- ...026-06-12-attachments-testflight-design.md | 113 ++++++++++-------- 1 file changed, 62 insertions(+), 51 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-attachments-testflight-design.md b/docs/superpowers/specs/2026-06-12-attachments-testflight-design.md index c7eb1d139a..8974475de5 100644 --- a/docs/superpowers/specs/2026-06-12-attachments-testflight-design.md +++ b/docs/superpowers/specs/2026-06-12-attachments-testflight-design.md @@ -1,87 +1,96 @@ -# Design: Photo/File Attachments + Local TestFlight Build +# Design: Attachment Extensions (Camera + Files) + Local TestFlight Build Date: 2026-06-12 -Status: Approved (brainstorm complete) +Status: Approved (rev 2 — architecture corrected after codebase audit) ## Goal -1. Add attachment support (photos, camera, arbitrary files/PDFs) to the Happy app, full scope of issue #1319 (also covers #1270, #919, #70), using upstream PR #554's architecture as a blueprint but implemented fresh on current main. +1. Extend the Happy app's existing image-attachment pipeline to the full scope of issue #1319 (camera capture, arbitrary files/PDFs), and fix the silent-drop of HEIC images. Covers issues #1270, #919, #70. 2. Ship a personal iOS build to TestFlight via local headless xcodebuild (App Store Connect API key signing — Seneca/SoundSpotter pattern), incorporating our open PRs #1372 (Fable 5) and #1373 (per-model effort + Opus 4.8[1m]) without waiting for upstream merge. +## What upstream main already has (do NOT rebuild) + +The audit found a complete, working image pipeline behind the `expImageUpload` settings flag: + +- App: `useImagePicker` hook (gallery, max 20 images, 10 MB cap), `AgentInputAttachmentStrip`, web paste/drag, picker button in `AgentInput`. +- Server: encrypted blob storage — `POST /v1/sessions/:id/attachments/request-upload` / `request-download`, presigned PUT (local) / POST (S3), 10 MB cap. +- E2E encryption: app encrypts with session blob key (`deriveKey(key, 'Happy Blobs', …)`), CLI decrypts via `decryptBlob` (tweetnacl secretbox). +- Protocol: `t:'file'` session events (schema in `happy-wire/src/sessionProtocol.ts:46-59`). +- CLI: `runClaude.ts:448` `onFileEvent` → `downloadAndDecryptAttachment` → `trackAttachmentDownload`; `drainAttachmentsForUserMessage` claims attachments per user message; `MessageQueue2` carries them; `claudeRemoteLauncher.ts:344-378` converts to SDK `image` content blocks via magic-byte `detectClaudeImageMime` (JPEG/PNG/GIF/WebP). + +PR #554's writeFile-RPC architecture is obsolete — upstream chose the server-blob route. + +## Gaps this design closes + +| # | Gap | Where | +|---|-----|-------| +| 1 | **HEIC silently dropped** — iOS gallery/camera HEIC fails magic-byte detection at `claudeRemoteLauncher.ts:358` and is skipped with only a debug log | App-side normalize before upload | +| 2 | **No camera capture** — picker goes straight to gallery | App | +| 3 | **No file/PDF attachment** — `expo-document-picker` installed (~55.0.0) but unused; CLI converts only images | App + CLI | +| 4 | **Oversized images** — originals uploaded at `quality: 1` with no downscale; >5 MB images exceed the Claude API per-image limit | App-side normalize | + ## Branch strategy ``` upstream/main - ├─ feat/fable-5-model (PR #1372, exists) - ├─ feat/claude-model-effort (PR #1373, exists, stacked on fable-5) - ├─ feat/attachments (NEW — clean off main; app + cli changes; PR upstream) - └─ local/testflight (NEW — integration branch: main + all 3 feature branches + ├─ feat/fable-5-model (PR #1372, exists — needs rebase, upstream moved) + ├─ feat/claude-model-effort (PR #1373, exists, stacked on fable-5 — needs rebase) + ├─ feat/attachments (THIS — clean off main; app + cli changes; PR upstream) + └─ local/testflight (integration: main + all 3 feature branches + one local-only commit: bundle ID, build script. - Never PRed upstream.) + Never PRed.) ``` -- TestFlight config (bundle ID swap, build script) lives only on `local/testflight`. -- Integration branch is rebuilt whenever upstream merges a PR; merged feature branches drop out naturally. -- happy-cli changes run from local dist via the existing daemon setup; the CLI is not part of the TestFlight artifact. +happy-cli changes run from local dist via the existing daemon setup; the CLI is not part of the TestFlight artifact. -## Attachment feature +## Feature design (feat/attachments) -### Architecture (from PR #554, extended) +### App: attachment source action sheet -``` -App picks attachment → (images: normalize) → chunked upload via RPC to CLI - machine's $TMPDIR/happy/uploads/{sessionId}/ → message text gains - [image: /path] / [file: /path] refs → agent reads files with Read tool -``` +`AgentInput`'s existing picker button (`onPickImages`) becomes "add attachment": opens a chooser — **Photo Library / Take Photo / Choose File** — via the app's `Modal.alert` button pattern (cross-platform; web keeps direct file behavior plus existing paste/drag). + +- Camera: `ImagePicker.launchCameraAsync` + `requestCameraPermissionsAsync`; `NSCameraUsageDescription` added to `app.config.js` `ios.infoPlist` (generic string, upstreamable). +- Files: `DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true })`; result flows through the same `AttachmentPreview` → upload → `t:'file'` event path (width/height 0, no thumbhash, `mimeType` from picker). +- Existing caps stay: max 20 attachments per message, 10 MB per file (server-enforced too). + +### App: image normalization before upload (quality-preserving) -Zero server/protocol changes. Reuses existing encrypted RPC channel. +In the picker/camera result path, before building `AttachmentPreview`: -### App side (`packages/happy-app/sources`) +- If format is not JPEG/PNG/GIF/WebP (e.g. HEIC), convert → JPEG quality 0.9 via `expo-image-manipulator`. +- If long edge > 1568 px (Claude vision API ceiling — the API downscales beyond this itself), downscale to 1568 px long edge. Also keeps payloads under the API's 5 MB per-image limit. +- Otherwise leave bytes untouched (no recompression of already-valid formats at acceptable size). +- Web `fileToAttachmentPreview` path gets the same rules via Canvas. -| Unit | Purpose | -|------|---------| -| `utils/attachments.ts` (native) | Gallery + camera via `expo-image-picker`; files via `expo-document-picker`. | -| `utils/attachments.web.ts` | File picker + clipboard paste (Canvas-based image normalize). Follows existing `.web` split pattern. | -| `utils/attachments.shared.ts` | Base64 validation, chunked RPC upload, upload-dir caching, filename sanitization. | -| `hooks/useAttachments.ts` | Pending-attachment state, max 5, pick/capture/remove handlers. | +### CLI: non-image attachment conversion -**Image handling (quality-preserving):** -- Downscale only if long edge > 1568 px (Claude vision API ceiling — API downscales beyond this itself; larger is pure waste). -- HEIC → JPEG conversion mandatory (vision API does not accept HEIC), JPEG quality 0.9. -- No aggressive 520 KB squeeze — images use the same chunked upload path as files. +Extend the conversion in `claudeRemoteLauncher.ts` (currently image-only): -**Files:** raw bytes, no transformation. 5 MB cap, error toast beyond. +- `%PDF-` magic → SDK `document` content block (`source: { type: 'base64', media_type: 'application/pdf' }`). +- Declared `text/*` mimeType (or extension fallback) that decodes as valid UTF-8 → `text` content block: fenced, prefixed with the filename. +- Anything else → skip, and (unlike today) emit a visible notice in the text block sent to the agent ("[attachment was not a supported type]") so failures aren't silent. -**UI (`AgentInput.tsx`):** -- “+” button in action bar → action sheet: Photo Library / Camera / Choose File. Count badge; disabled at 5. -- Chips strip above input: thumbnail (images) or file icon + name, per-chip remove. -- On send: upload all pending, then append `[image: /path]` / `[file: /path]` refs to the message text. +`PendingAttachment` already carries `{ data, mimeType, name }` — no protocol change. -**Permissions:** `NSCameraUsageDescription`, `NSPhotoLibraryUsageDescription` via `app.config.js`. +### i18n -**i18n:** new keys in `text/_default.ts` + all 11 translation files. +New keys under the existing `imageUpload` section of `text/_default.ts` (chooser labels, camera permission, unsupported-type) mirrored into all 10 translation files (`ca, en, es, it, ja, pl, pt, ru, zh-Hans, zh-Hant`). -### CLI side (`packages/happy-cli`) +### Settings flag -- `getUploadDir` RPC → returns `$TMPDIR/happy/uploads/{sessionId}/` (created on demand). -- `pathSecurity.validatePath` gains `additionalAllowedDirs` covering the upload dir. -- **Chunked upload:** new `appendFile` RPC (or offset parameter on `writeFile`); app loops 256 KB chunks. Socket.io payload limit is 1 MB, so single-shot writes cap at ~520 KB base64 — chunking lifts that for both large images and files. -- Strip base64 image data from tool results before socket transport (agent Read of an image file otherwise overflows the socket) — ported from #554. -- System prompt addition instructing the agent to use Read on `[image:]` / `[file:]` refs. +Feature stays behind the existing `expImageUpload` flag (Settings → Features). Label copy updated from "images" to "attachments". Flag flipped on in our local build; upstream default untouched. ### Testing -- pathSecurity traversal tests (upload dir allowlist). -- Chunked-write reassembly test (CLI). -- `useAttachments` hook tests (max-5, remove, send-clears). -- Image normalize tests (downscale threshold, HEIC conversion path where mockable). +- CLI (vitest, colocated `*.test.ts`): content-block conversion — PDF magic → document block, UTF-8 text → text block, unknown bytes → notice; HEIC bytes still skipped at CLI (defense in depth). +- App (vitest, pattern: `settings.spec.ts`): normalization decision logic (format/size → convert/downscale/passthrough) extracted as a pure function and tested; document-picker → `AttachmentPreview` mapping. ## TestFlight build (local/testflight only) ### One-time manual prereqs (public ASC API cannot create app records) 1. Register bundle ID `ca.lixfeld.happy` in the Apple Developer portal. -2. Create the ASC app record against it (name e.g. “Happy JL”). TestFlight-only; never App Store. +2. Create the ASC app record against it (name e.g. "Happy JL"). TestFlight-only; never App Store. ### `scripts/build-ios-testflight.sh` @@ -98,17 +107,19 @@ Both `archive` and `-exportArchive` carry the three `-authenticationKey*` flags ### Local-only commit contents -- `app.config.js`: bundle ID → `ca.lixfeld.happy`, display-name tweak. +- `app.config.js`: production bundle ID → `ca.lixfeld.happy` (today `com.ex3ndr.happy`), display-name tweak. - `scripts/build-ios-testflight.sh`. - Timestamp-based build-number auto-increment. +- `expImageUpload` default flip (local convenience). ### Known limitation -Push notifications do not work in the fork build: happy-server sends APNs pushes with slopus’ credentials, which are tied to their bundle ID. Pairing and E2E sync against api.happy-servers.com are unaffected (bundle ID is irrelevant to the backend protocol). +Push notifications do not work in the fork build: happy-server sends APNs pushes with slopus' credentials, tied to their bundle ID. Pairing and E2E sync against api.happy-servers.com are unaffected. ## Out of scope -- Server changes of any kind. +- Server changes of any kind (10 MB cap stays). - Android build/distribution. - App Store (non-TestFlight) release. - Restoring push notifications in the fork build. +- Chunked upload (server cap is 10 MB; presigned upload path already handles that size). From a63b3eec74cc2c2f7b1c4d52480f19a5f0ec8e68 Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:25:15 -0400 Subject: [PATCH 03/11] docs: implementation plan for attachments + TestFlight build Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../2026-06-12-attachments-testflight.md | 886 ++++++++++++++++++ 1 file changed, 886 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-attachments-testflight.md diff --git a/docs/superpowers/plans/2026-06-12-attachments-testflight.md b/docs/superpowers/plans/2026-06-12-attachments-testflight.md new file mode 100644 index 0000000000..fccbcd76d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-attachments-testflight.md @@ -0,0 +1,886 @@ +# Attachments (Camera + Files) + Local TestFlight Build — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend Happy's existing image-attachment pipeline with camera capture, file/PDF attachments, and HEIC/size normalization; then ship a personal iOS TestFlight build that bundles PRs #1372, #1373, and this feature. + +**Architecture:** Upstream main already has the full image pipeline (app picker → encrypted server blob → `t:'file'` session event → CLI download/decrypt → SDK `image` content block), gated by the `expImageUpload` setting. We extend the edges only: app-side capture sources + normalization, CLI-side non-image content-block conversion. TestFlight ships from a local integration branch via headless xcodebuild + ASC API key. + +**Tech Stack:** Expo SDK 55 (expo-image-picker, expo-image-manipulator, expo-document-picker — all already in package.json), vitest, @anthropic-ai/sdk content blocks, xcodebuild + altool. + +**Conventions (from packages/happy-app/CLAUDE.md):** 4-space indent, `t(...)` for ALL user-visible strings added to ALL 10 translation files, `Modal` from `@/modal` (never RN Alert), `pnpm` only, run `pnpm typecheck` after app changes. Commit messages end with the Happy/Claude co-author block: + +``` +Generated with [Claude Code](https://claude.ai/code) +via [Happy](https://happy.engineering) + +Co-Authored-By: Claude +Co-Authored-By: Happy +``` + +**Branch:** all Phase A tasks on `feat/attachments` (already exists, off upstream/main). Phase B on `local/testflight`. + +**Working dir:** `/Users/jlixfeld/Code/happy`. + +--- + +## Phase A — feature (upstream PR) + +### Task 1: Image normalization decision logic (pure function) + +**Files:** +- Create: `packages/happy-app/sources/utils/attachmentNormalize.ts` +- Test: `packages/happy-app/sources/utils/attachmentNormalize.spec.ts` + +The CLI drops any attachment whose bytes aren't JPEG/PNG/GIF/WebP (`claudeRemoteLauncher.ts:358` skips HEIC silently). The Claude API also caps images at 5 MB and downscales anything over 1568 px long edge. Normalize app-side, before upload. + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/happy-app/sources/utils/attachmentNormalize.spec.ts +import { describe, it, expect } from 'vitest'; +import { planImageNormalization, CLAUDE_VISION_MAX_EDGE } from './attachmentNormalize'; + +describe('planImageNormalization', () => { + it('passes through a small JPEG untouched', () => { + expect(planImageNormalization({ mimeType: 'image/jpeg', width: 800, height: 600 })) + .toEqual({ action: 'passthrough' }); + }); + + it('converts HEIC to JPEG', () => { + expect(planImageNormalization({ mimeType: 'image/heic', width: 800, height: 600 })) + .toEqual({ action: 'normalize', resize: undefined }); + }); + + it('downscales an oversized JPEG to 1568px long edge (landscape)', () => { + expect(planImageNormalization({ mimeType: 'image/jpeg', width: 4032, height: 3024 })) + .toEqual({ action: 'normalize', resize: { width: CLAUDE_VISION_MAX_EDGE } }); + }); + + it('downscales an oversized PNG to 1568px long edge (portrait)', () => { + expect(planImageNormalization({ mimeType: 'image/png', width: 3024, height: 4032 })) + .toEqual({ action: 'normalize', resize: { height: CLAUDE_VISION_MAX_EDGE } }); + }); + + it('passes through supported formats at exactly the ceiling', () => { + expect(planImageNormalization({ mimeType: 'image/webp', width: 1568, height: 1000 })) + .toEqual({ action: 'passthrough' }); + }); + + it('normalizes unknown/missing mime types defensively', () => { + expect(planImageNormalization({ mimeType: undefined, width: 800, height: 600 })) + .toEqual({ action: 'normalize', resize: undefined }); + }); + + it('treats zero dimensions as unknown size — converts format only', () => { + expect(planImageNormalization({ mimeType: 'image/heic', width: 0, height: 0 })) + .toEqual({ action: 'normalize', resize: undefined }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/happy-app && pnpm vitest run sources/utils/attachmentNormalize.spec.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +```typescript +// packages/happy-app/sources/utils/attachmentNormalize.ts +/** + * Decides whether a picked image needs normalization before upload. + * + * The CLI converts attachments to Claude API image blocks by magic-byte + * sniffing and SKIPS anything that isn't JPEG/PNG/GIF/WebP — iOS HEIC would + * be silently dropped. The Claude vision API also downscales anything over + * 1568px long edge server-side and rejects images over 5MB, so uploading + * larger is pure waste. Pure function — tested in attachmentNormalize.spec.ts. + */ + +export const CLAUDE_VISION_MAX_EDGE = 1568; +export const NORMALIZE_JPEG_QUALITY = 0.9; + +const CLAUDE_SUPPORTED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); + +export type NormalizationPlan = + | { action: 'passthrough' } + | { action: 'normalize'; resize: { width: number } | { height: number } | undefined }; + +export function planImageNormalization(input: { + mimeType: string | undefined; + width: number; + height: number; +}): NormalizationPlan { + const supported = input.mimeType !== undefined && CLAUDE_SUPPORTED_MIMES.has(input.mimeType); + const longEdge = Math.max(input.width, input.height); + const oversized = longEdge > CLAUDE_VISION_MAX_EDGE; + + if (supported && !oversized) { + return { action: 'passthrough' }; + } + + let resize: { width: number } | { height: number } | undefined = undefined; + if (oversized) { + resize = input.width >= input.height + ? { width: CLAUDE_VISION_MAX_EDGE } + : { height: CLAUDE_VISION_MAX_EDGE }; + } + return { action: 'normalize', resize }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/happy-app && pnpm vitest run sources/utils/attachmentNormalize.spec.ts` +Expected: 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add packages/happy-app/sources/utils/attachmentNormalize.ts packages/happy-app/sources/utils/attachmentNormalize.spec.ts +git commit -m "feat(app): image normalization decision logic for attachments" +``` + +--- + +### Task 2: Native normalization executor (expo-image-manipulator) + +**Files:** +- Modify: `packages/happy-app/sources/utils/attachmentNormalize.ts` (append function) + +- [ ] **Step 1: Verify the expo-image-manipulator 55 API surface before writing code** + +Run: `sed -n 1,80p packages/happy-app/node_modules/expo-image-manipulator/build/ImageManipulator.d.ts` and check for the object-context API (`ImageManipulator.manipulate(uri)` → context with `.resize()` / `.renderAsync()` / `.saveAsync()`), and the legacy `manipulateAsync` export. Use whichever the installed version documents as current; the code below assumes the SDK 52+ object API. If only `manipulateAsync(uri, actions, saveOptions)` exists, use that form instead with the same actions/options. + +- [ ] **Step 2: Append the executor** + +```typescript +// append to packages/happy-app/sources/utils/attachmentNormalize.ts +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'; + +/** + * Applies a normalization plan to an image URI. Returns the (possibly new) + * uri + dimensions + mime. Native + web (expo-image-manipulator supports both). + * Not unit-tested — exercised manually; the decision logic above carries the tests. + */ +export async function normalizeImage( + uri: string, + plan: NormalizationPlan, +): Promise<{ uri: string; width: number; height: number; mimeType: string } | null> { + if (plan.action === 'passthrough') return null; + const context = ImageManipulator.manipulate(uri); + if (plan.resize) { + context.resize(plan.resize); + } + const image = await context.renderAsync(); + const result = await image.saveAsync({ format: SaveFormat.JPEG, compress: NORMALIZE_JPEG_QUALITY }); + return { uri: result.uri, width: result.width, height: result.height, mimeType: 'image/jpeg' }; +} +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd packages/happy-app && pnpm typecheck` +Expected: clean. (If the import shape differs per Step 1, adjust and re-run.) + +- [ ] **Step 4: Commit** + +```bash +git add packages/happy-app/sources/utils/attachmentNormalize.ts +git commit -m "feat(app): expo-image-manipulator normalization executor" +``` + +--- + +### Task 3: Wire normalization + camera + file picking into the picker hook + +**Files:** +- Modify: `packages/happy-app/sources/hooks/useImagePicker.ts` + +The hook currently exposes `{ selectedImages, pickImages, removeImage, clearImages, addImages }` (see file, 136 lines). Add `takePhoto` and `pickFiles`, and run every picked/captured image through normalization. Keep the existing name and signature — additive only. + +- [ ] **Step 1: Extract the shared asset→preview conversion and add normalization** + +Inside `useImagePicker`, above `pickImages`, add: + +```typescript + // Shared by gallery + camera: enforce size cap, normalize (HEIC→JPEG, + // downscale >1568px — see attachmentNormalize.ts), generate thumbhash. + const assetsToPreviews = useCallback(async (assets: ImagePicker.ImagePickerAsset[]): Promise => { + const previews: AttachmentPreview[] = []; + for (const asset of assets) { + let { uri, width, height } = asset; + let mimeType = asset.mimeType ?? undefined; + let size = asset.fileSize ?? 0; + + const plan = planImageNormalization({ mimeType, width, height }); + const normalized = await normalizeImage(uri, plan).catch(() => null); + if (normalized) { + uri = normalized.uri; + width = normalized.width; + height = normalized.height; + mimeType = normalized.mimeType; + size = 0; // unknown after re-encode; server enforces the cap + } + + if (size > MAX_FILE_SIZE) { + Modal.alert( + t('imageUpload.fileTooLargeTitle'), + t('imageUpload.fileTooLargeMessage', { name: asset.fileName ?? 'image', maxMb: 10 }), + [{ text: t('common.ok') }], + ); + continue; + } + + const thumbhash = (width > 0 && height > 0) + ? await generateThumbhash(uri, width, height) + : undefined; + + previews.push({ + id: `${Date.now()}_${Math.random().toString(36).slice(2)}`, + uri, + width, + height, + mimeType: mimeType ?? 'image/jpeg', + size, + name: asset.fileName ?? `image_${Date.now()}.jpg`, + thumbhash, + }); + } + return previews; + }, []); +``` + +Add imports at top: `import { planImageNormalization, normalizeImage } from '@/utils/attachmentNormalize';` and `import * as DocumentPicker from 'expo-document-picker';`. + +Replace the body of the existing `for (const asset of assets)` loop in `pickImages` (lines 84–111) with a call to the shared helper: + +```typescript + const previews = await assetsToPreviews(assets); +``` + +(delete the now-redundant inline loop; keep the `remaining` clamp and the `setSelectedImages` tail unchanged; add `assetsToPreviews` to the `pickImages` dependency array.) + +- [ ] **Step 2: Add `takePhoto`** + +```typescript + const takePhoto = useCallback(async () => { + if (Platform.OS !== 'web') { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== 'granted') { + Modal.alert( + t('imageUpload.cameraPermissionTitle'), + t('imageUpload.cameraPermissionMessage'), + [{ text: t('common.ok') }], + ); + return; + } + } + if (MAX_IMAGES_PER_MESSAGE - selectedCountRef.current <= 0) { + Modal.alert( + t('imageUpload.limitTitle'), + t('imageUpload.limitMessage', { max: MAX_IMAGES_PER_MESSAGE }), + [{ text: t('common.ok') }], + ); + return; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + quality: 1, // normalization handles size/format downstream + exif: false, + }); + if (result.canceled || !result.assets.length) return; + + const previews = await assetsToPreviews(result.assets); + if (previews.length > 0) { + setSelectedImages(prev => [...prev, ...previews].slice(0, MAX_IMAGES_PER_MESSAGE)); + } + }, [assetsToPreviews]); +``` + +- [ ] **Step 3: Add `pickFiles`** + +Files reuse the same `AttachmentPreview` shape with `width/height: 0` and no thumbhash — the existing upload path (`sync.ts uploadAttachmentsForSession`) and `t:'file'` event already carry `mimeType` and omit the `image` sub-object when dimensions are 0. + +```typescript + const pickFiles = useCallback(async () => { + const remaining = MAX_IMAGES_PER_MESSAGE - selectedCountRef.current; + if (remaining <= 0) { + Modal.alert( + t('imageUpload.limitTitle'), + t('imageUpload.limitMessage', { max: MAX_IMAGES_PER_MESSAGE }), + [{ text: t('common.ok') }], + ); + return; + } + + const result = await DocumentPicker.getDocumentAsync({ + multiple: true, + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets.length) return; + + const previews: AttachmentPreview[] = []; + for (const asset of result.assets.slice(0, remaining)) { + const size = asset.size ?? 0; + if (size > MAX_FILE_SIZE) { + Modal.alert( + t('imageUpload.fileTooLargeTitle'), + t('imageUpload.fileTooLargeMessage', { name: asset.name, maxMb: 10 }), + [{ text: t('common.ok') }], + ); + continue; + } + previews.push({ + id: `${Date.now()}_${Math.random().toString(36).slice(2)}`, + uri: asset.uri, + width: 0, + height: 0, + mimeType: asset.mimeType ?? 'application/octet-stream', + size, + name: asset.name, + thumbhash: undefined, + }); + } + if (previews.length > 0) { + setSelectedImages(prev => [...prev, ...previews].slice(0, MAX_IMAGES_PER_MESSAGE)); + } + }, []); +``` + +- [ ] **Step 4: Extend the result type and return** + +```typescript +type UseImagePickerResult = { + selectedImages: AttachmentPreview[]; + pickImages: () => Promise; + takePhoto: () => Promise; + pickFiles: () => Promise; + removeImage: (id: string) => void; + clearImages: () => void; + addImages: (images: AttachmentPreview[]) => void; +}; +``` + +Return: `{ selectedImages, pickImages, takePhoto, pickFiles, removeImage, clearImages, addImages }`. + +Update the hook's doc comment (lines 1–11) to mention camera + files. + +- [ ] **Step 5: Typecheck + existing tests** + +Run: `cd packages/happy-app && pnpm typecheck && pnpm vitest run` +Expected: clean / all pass. + +- [ ] **Step 6: Commit** + +```bash +git add packages/happy-app/sources/hooks/useImagePicker.ts +git commit -m "feat(app): camera capture and file picking in attachment hook" +``` + +--- + +### Task 4: Attachment-source chooser in SessionView + +**Files:** +- Modify: `packages/happy-app/sources/-session/SessionView.tsx` +- Modify: `packages/happy-app/app.config.js` + +- [ ] **Step 1: Replace the direct gallery call with a chooser** + +In `SessionView.tsx`, the hook destructure becomes: + +```typescript + const { selectedImages, pickImages, takePhoto, pickFiles, removeImage, clearImages, addImages } = useImagePicker(); +``` + +Below it, add (web keeps the direct file-picker behavior — paste/drag already covers images there, and a camera option is meaningless on desktop): + +```typescript + const handlePickAttachment = React.useCallback(() => { + if (Platform.OS === 'web') { + pickImages(); + return; + } + Modal.alert(t('imageUpload.addTitle'), undefined, [ + { text: t('imageUpload.optionLibrary'), onPress: () => { pickImages(); } }, + { text: t('imageUpload.optionCamera'), onPress: () => { takePhoto(); } }, + { text: t('imageUpload.optionFiles'), onPress: () => { pickFiles(); } }, + { text: t('common.cancel'), style: 'cancel' }, + ]); + }, [pickImages, takePhoto, pickFiles]); +``` + +Change the AgentInput prop wiring from `onPickImages={expImageUpload ? pickImages : undefined}` to `onPickImages={expImageUpload ? handlePickAttachment : undefined}`. + +Check imports: `Platform` from `react-native`, `Modal` from `@/modal`, `t` from `@/text` — add any missing. + +Note: `Modal.alert`'s `AlertButton` follows the RN shape (`{ text, onPress?, style? }`) — confirm against `sources/modal/types.ts` if typecheck complains. + +- [ ] **Step 2: Add the camera permission string to app.config.js** + +In the `ios.infoPlist` block (lines ~75–85), after `NSMicrophoneUsageDescription`: + +```javascript + NSCameraUsageDescription: "Allow $(PRODUCT_NAME) to use the camera to take photos to attach to messages.", +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd packages/happy-app && pnpm typecheck` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add packages/happy-app/sources/-session/SessionView.tsx packages/happy-app/app.config.js +git commit -m "feat(app): attachment source chooser (library/camera/files)" +``` + +--- + +### Task 5: i18n keys (all 10 languages) + +**Files:** +- Modify: `packages/happy-app/sources/text/_default.ts` (the `imageUpload` section, ~line 223, and `settings.imageUpload*` ~line 219) +- Modify: all of `packages/happy-app/sources/text/translations/{ca,en,es,it,ja,pl,pt,ru,zh-Hans,zh-Hant}.ts` + +- [ ] **Step 1: Add keys to `_default.ts`** (inside the existing `imageUpload` section): + +```typescript + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', +``` + +Also update the settings labels (~line 219): + +```typescript + imageUpload: 'Attachments', + imageUploadSubtitle: 'Attach images and files to messages for Claude to analyze', +``` + +- [ ] **Step 2: Add the same keys to every translation file.** Mirror the structure of the existing `imageUpload` section in each file. Translations: + +| key | en | es | ca | it | pt | pl | ru | ja | zh-Hans | zh-Hant | +|---|---|---|---|---|---|---|---|---|---|---| +| addTitle | Add Attachment | Añadir adjunto | Afegeix un fitxer adjunt | Aggiungi allegato | Adicionar anexo | Dodaj załącznik | Добавить вложение | 添付ファイルを追加 | 添加附件 | 新增附件 | +| optionLibrary | Photo Library | Fototeca | Fototeca | Libreria foto | Biblioteca de fotos | Biblioteka zdjęć | Фототека | フォトライブラリ | 照片图库 | 照片圖庫 | +| optionCamera | Take Photo | Tomar foto | Fes una foto | Scatta foto | Tirar foto | Zrób zdjęcie | Сделать фото | 写真を撮る | 拍照 | 拍照 | +| optionFiles | Choose File | Elegir archivo | Tria un fitxer | Scegli file | Escolher arquivo | Wybierz plik | Выбрать файл | ファイルを選択 | 选择文件 | 選擇檔案 | +| cameraPermissionTitle | Camera Access | Acceso a la cámara | Accés a la càmera | Accesso alla fotocamera | Acesso à câmera | Dostęp do aparatu | Доступ к камере | カメラへのアクセス | 相机权限 | 相機權限 | +| cameraPermissionMessage | Allow camera access to take photos to attach to messages. | Permite el acceso a la cámara para tomar fotos y adjuntarlas a los mensajes. | Permet l'accés a la càmera per fer fotos i adjuntar-les als missatges. | Consenti l'accesso alla fotocamera per scattare foto da allegare ai messaggi. | Permita o acesso à câmera para tirar fotos e anexá-las às mensagens. | Zezwól na dostęp do aparatu, aby robić zdjęcia i dołączać je do wiadomości. | Разрешите доступ к камере, чтобы делать фото и прикреплять их к сообщениям. | メッセージに添付する写真を撮るには、カメラへのアクセスを許可してください。 | 允许访问相机以拍摄照片并附加到消息中。 | 允許存取相機以拍攝照片並附加到訊息中。 | +| settings.imageUpload | Attachments | Adjuntos | Fitxers adjunts | Allegati | Anexos | Załączniki | Вложения | 添付ファイル | 附件 | 附件 | +| settings.imageUploadSubtitle | Attach images and files to messages for Claude to analyze | Adjunta imágenes y archivos a los mensajes para que Claude los analice | Adjunta imatges i fitxers als missatges perquè Claude els analitzi | Allega immagini e file ai messaggi per l'analisi di Claude | Anexe imagens e arquivos às mensagens para o Claude analisar | Dołączaj obrazy i pliki do wiadomości do analizy przez Claude | Прикрепляйте изображения и файлы к сообщениям для анализа Claude | 画像やファイルをメッセージに添付して Claude に分析させる | 在消息中附加图片和文件供 Claude 分析 | 在訊息中附加圖片和檔案供 Claude 分析 | + +Before editing, read each translation file's `imageUpload` section to match its exact local structure (some files may interleave comments). + +- [ ] **Step 3: Typecheck** (translation files are typed against `_default.ts` — missing keys fail here) + +Run: `cd packages/happy-app && pnpm typecheck` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add packages/happy-app/sources/text/ +git commit -m "feat(app): i18n strings for attachment chooser and camera permission" +``` + +--- + +### Task 6: CLI — non-image attachments become document/text content blocks + +**Files:** +- Create: `packages/happy-cli/src/claude/utils/attachmentContentBlocks.ts` +- Test: `packages/happy-cli/src/claude/utils/attachmentContentBlocks.test.ts` +- Modify: `packages/happy-cli/src/claude/claudeRemoteLauncher.ts` (replace inline conversion at lines ~344–378, delete the now-moved `detectClaudeImageMime` at ~line 530) + +- [ ] **Step 1: Write the failing tests** + +```typescript +// packages/happy-cli/src/claude/utils/attachmentContentBlocks.test.ts +import { describe, it, expect } from 'vitest'; +import { attachmentsToContentBlocks } from './attachmentContentBlocks'; + +const PNG = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3]); +const PDF = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]); // "%PDF-1.4" +const TEXT = new TextEncoder().encode('hello\nworld'); +const BINARY = new Uint8Array([0x00, 0xFF, 0x13, 0x37, 0x00, 0x01]); + +describe('attachmentsToContentBlocks', () => { + it('converts a PNG to an image block', () => { + const blocks = attachmentsToContentBlocks( + [{ data: PNG, mimeType: 'image/png', name: 'shot.png' }], 'look'); + expect(blocks[0]).toMatchObject({ type: 'image', source: { type: 'base64', media_type: 'image/png' } }); + expect(blocks[blocks.length - 1]).toEqual({ type: 'text', text: 'look' }); + }); + + it('converts a PDF to a document block regardless of declared mime', () => { + const blocks = attachmentsToContentBlocks( + [{ data: PDF, mimeType: 'application/octet-stream', name: 'doc.pdf' }], 'read'); + expect(blocks[0]).toMatchObject({ type: 'document', source: { type: 'base64', media_type: 'application/pdf' } }); + }); + + it('inlines text/* attachments as fenced text blocks with filename', () => { + const blocks = attachmentsToContentBlocks( + [{ data: TEXT, mimeType: 'text/plain', name: 'log.txt' }], 'check'); + expect(blocks[0].type).toBe('text'); + const text = (blocks[0] as { type: 'text'; text: string }).text; + expect(text).toContain('log.txt'); + expect(text).toContain('hello\nworld'); + }); + + it('inlines UTF-8 attachments with unknown mime by extension fallback', () => { + const blocks = attachmentsToContentBlocks( + [{ data: TEXT, mimeType: 'application/octet-stream', name: 'notes.md' }], 'check'); + expect(blocks[0].type).toBe('text'); + }); + + it('emits a visible notice for unsupported binary attachments', () => { + const blocks = attachmentsToContentBlocks( + [{ data: BINARY, mimeType: 'application/octet-stream', name: 'blob.bin' }], 'hi'); + const last = blocks[blocks.length - 1] as { type: 'text'; text: string }; + expect(last.type).toBe('text'); + expect(last.text).toContain('blob.bin'); + expect(last.text).toContain('not a supported'); + expect(last.text).toContain('hi'); + }); + + it('returns a single text block when there are no attachments', () => { + expect(attachmentsToContentBlocks([], 'just text')) + .toEqual([{ type: 'text', text: 'just text' }]); + }); + + it('skips HEIC bytes that fail magic detection (defense in depth)', () => { + const heicish = new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); // ftyp box, no JPEG/PNG magic + const blocks = attachmentsToContentBlocks( + [{ data: heicish, mimeType: 'image/heic', name: 'pic.heic' }], 'hi'); + const last = blocks[blocks.length - 1] as { type: 'text'; text: string }; + expect(last.text).toContain('pic.heic'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/happy-cli && pnpm vitest run src/claude/utils/attachmentContentBlocks.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +Move `detectClaudeImageMime` (currently `claudeRemoteLauncher.ts:530-548`, copy verbatim) into the new module and build around it: + +```typescript +// packages/happy-cli/src/claude/utils/attachmentContentBlocks.ts +/** + * Converts decrypted attachments into Claude API content blocks. + * + * Routing, in priority order on the decrypted BYTES (wire mimeType is + * advisory only — iOS pickers lie): + * 1. JPEG/PNG/GIF/WebP magic -> image block + * 2. %PDF- magic -> document block (application/pdf) + * 3. text/* mime, known text extension, or clean UTF-8 decode -> fenced text block + * 4. anything else -> visible notice appended to the text message + * (previously these were dropped with only a debug log) + */ +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'; + +export type PendingAttachmentLike = { data: Uint8Array; mimeType: string; name: string }; + +export function detectClaudeImageMime(bytes: Uint8Array): 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | null { + if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { + return 'image/png'; + } + if (bytes.length >= 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) { + return 'image/jpeg'; + } + if (bytes.length >= 4 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { + return 'image/gif'; + } + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 + ) { + return 'image/webp'; + } + return null; +} + +function isPdf(bytes: Uint8Array): boolean { + return bytes.length >= 5 && + bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46 && bytes[4] === 0x2D; +} + +const TEXT_EXTENSIONS = new Set([ + 'txt', 'md', 'log', 'json', 'yaml', 'yml', 'csv', 'xml', 'html', 'css', + 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'h', 'cpp', + 'sh', 'toml', 'ini', 'cfg', 'conf', 'sql', 'swift', 'kt', 'env', +]); + +function decodeAsText(att: PendingAttachmentLike): string | null { + const ext = att.name.includes('.') ? att.name.split('.').pop()!.toLowerCase() : ''; + const looksTextual = att.mimeType.startsWith('text/') || TEXT_EXTENSIONS.has(ext); + try { + const decoded = new TextDecoder('utf-8', { fatal: true }).decode(att.data); + // Reject decodes full of control chars even if technically valid UTF-8, + // unless mime/extension already vouches for it. + if (!looksTextual && /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(decoded)) return null; + return decoded; + } catch { + return null; + } +} + +export function attachmentsToContentBlocks( + attachments: PendingAttachmentLike[], + messageText: string, +): ContentBlockParam[] { + const blocks: ContentBlockParam[] = []; + const unsupported: string[] = []; + + for (const att of attachments) { + const imageMime = detectClaudeImageMime(att.data); + if (imageMime) { + blocks.push({ + type: 'image', + source: { type: 'base64', media_type: imageMime, data: Buffer.from(att.data).toString('base64') }, + }); + continue; + } + if (isPdf(att.data)) { + blocks.push({ + type: 'document', + source: { type: 'base64', media_type: 'application/pdf', data: Buffer.from(att.data).toString('base64') }, + }); + continue; + } + const text = decodeAsText(att); + if (text !== null) { + blocks.push({ type: 'text', text: `Attached file "${att.name}":\n\`\`\`\n${text}\n\`\`\`` }); + continue; + } + unsupported.push(att.name); + } + + let tail = messageText; + if (unsupported.length > 0) { + tail += `\n\n[Note: attachment(s) ${unsupported.map(n => `"${n}"`).join(', ')} were not a supported type and were omitted.]`; + } + blocks.push({ type: 'text', text: tail }); + return blocks; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/happy-cli && pnpm vitest run src/claude/utils/attachmentContentBlocks.test.ts` +Expected: 7 passed. If the SDK's `ContentBlockParam` union rejects the `document` literal, check `node_modules/@anthropic-ai/sdk/resources/messages.d.ts` for the exact `DocumentBlockParam`/`Base64PDFSource` shape and conform. + +- [ ] **Step 5: Replace the inline conversion in claudeRemoteLauncher.ts** + +At lines ~344–378, replace the whole `if (attachments.length > 0) { ... }` body with: + +```typescript + const attachments = msg.attachments ?? []; + if (attachments.length > 0) { + const contentBlocks = attachmentsToContentBlocks(attachments, msg.message); + logger.debug(`[remote] Combined ${contentBlocks.length - 1} attachment block(s) with text message`); + return { + message: contentBlocks, + mode: msg.mode, + }; + } +``` + +Add the import (`import { attachmentsToContentBlocks } from '@/claude/utils/attachmentContentBlocks'` — match the file's existing import style/aliases, check its other `@/claude/...` imports; use a relative path if that's the convention). Delete the old `detectClaudeImageMime` function (~line 530) and its now-unused comment block. Remove the `ContentBlockParam` import if no longer referenced. + +- [ ] **Step 6: Full CLI test suite + build** + +Run: `cd packages/happy-cli && pnpm vitest run && pnpm build` +Expected: all pass, build clean. (Check package.json — if the build script is named differently, e.g. `compile`, use that.) + +- [ ] **Step 7: Commit** + +```bash +git add packages/happy-cli/src/claude/utils/attachmentContentBlocks.ts packages/happy-cli/src/claude/utils/attachmentContentBlocks.test.ts packages/happy-cli/src/claude/claudeRemoteLauncher.ts +git commit -m "feat(cli): convert PDF and text attachments to Claude content blocks" +``` + +--- + +### Task 7: Full verification + upstream PR + +- [ ] **Step 1: Run everything** + +```bash +cd packages/happy-app && pnpm typecheck && pnpm vitest run +cd ../happy-cli && pnpm vitest run && pnpm typecheck +``` + +Expected: all clean. + +- [ ] **Step 2: Push and open the PR** + +```bash +git push -u origin feat/attachments +gh pr create --repo slopus/happy --title "feat: camera, file/PDF attachments + HEIC normalization" --body "" +``` + +PR body: summarize — extends `expImageUpload` pipeline with camera capture + document picker; app-side normalization (HEIC→JPEG q0.9, downscale >1568px long edge — fixes silent HEIC drop at the CLI magic-byte check); CLI converts PDFs to document blocks and UTF-8 text files to fenced text blocks, with a visible notice for unsupported types. Reference issues #1319, #1270, #919, #70. End body with the 🤖 Generated with Claude Code footer. + +Note: the spec/plan docs under `docs/superpowers/` are committed on this branch — move them to the final commit only if upstream wouldn't want them; default: keep them out of the PR by rebasing them onto a separate local branch if the maintainer objects. (Leave as-is initially; they're harmless docs.) + +--- + +## Phase B — local TestFlight build + +### Task 8: Integration branch + +- [ ] **Step 1: Create `local/testflight`** + +```bash +git fetch upstream main +git checkout -b local/testflight upstream/main +git merge --no-edit feat/fable-5-model +git merge --no-edit feat/claude-model-effort +git merge --no-edit feat/attachments +``` + +Expected: clean merges or small conflicts (upstream moved since #1372/#1373 branched — resolve keeping both sides' intent; the PR branches touch model lists/picker, attachments touches picker hook/SessionView). + +- [ ] **Step 2: Verify merged tree** + +```bash +cd packages/happy-app && pnpm typecheck && pnpm vitest run +cd ../happy-cli && pnpm vitest run +``` + +Expected: clean. + +- [ ] **Step 3: Commit nothing extra yet** — merges only on this branch so far. + +### Task 9: Local-only TestFlight commit + +**Files:** +- Modify: `packages/happy-app/app.config.js` +- Create: `scripts/build-ios-testflight.sh` + +- [ ] **Step 1: app.config.js overrides** + +In the `bundleId` map (~line 9), change production: `production: "ca.lixfeld.happy"`. In the `name` map (find the equivalent display-name variant map near it — read lines 1–68 first), suffix the production name with nothing visible-breaking (keep "Happy"; ASC app record name disambiguates). Add `buildNumber` support inside the `ios` block: + +```javascript + buildNumber: process.env.HAPPY_BUILD_NUMBER ?? "1", +``` + +Flip the settings default for convenience: in `packages/happy-app/sources/sync/settings.ts:104`, `expImageUpload: true`. + +- [ ] **Step 2: Build script** — create `scripts/build-ios-testflight.sh` (chmod +x): + +```bash +#!/usr/bin/env bash +# Local TestFlight build for the Happy fork (bundle ca.lixfeld.happy). +# Headless signing via App Store Connect API key — Seneca/SoundSpotter pattern. +# Requires: APPLE_ASC_KEY_ID, APPLE_ASC_ISSUER_ID, APPLE_TEAM_ID in env (Infisical). +set -euo pipefail + +cd "$(dirname "$0")/../packages/happy-app" + +: "${APPLE_ASC_KEY_ID:?APPLE_ASC_KEY_ID not set}" +: "${APPLE_ASC_ISSUER_ID:?APPLE_ASC_ISSUER_ID not set}" +: "${APPLE_TEAM_ID:?APPLE_TEAM_ID not set}" + +P8_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_ASC_KEY_ID}.p8" +[[ -f "$P8_PATH" ]] || { echo "ERROR: ASC API key not found at $P8_PATH" >&2; exit 1; } + +export HAPPY_BUILD_NUMBER="$(date +%y%m%d%H%M)" +BUILD_DIR="$(pwd)/build-testflight" +rm -rf "$BUILD_DIR" ios +mkdir -p "$BUILD_DIR" + +echo "==> Prebuild (APP_ENV=production, buildNumber=$HAPPY_BUILD_NUMBER)" +APP_ENV=production npx expo prebuild --platform ios + +WORKSPACE=$(ls ios/*.xcworkspace | head -1) +SCHEME=$(basename "$WORKSPACE" .xcworkspace) + +echo "==> Archive" +xcodebuild archive \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$BUILD_DIR/Happy.xcarchive" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_STYLE=Automatic \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$P8_PATH" \ + -authenticationKeyID "$APPLE_ASC_KEY_ID" \ + -authenticationKeyIssuerID "$APPLE_ASC_ISSUER_ID" + +cat > "$BUILD_DIR/ExportOptions.plist" < + + + + methodapp-store-connect + teamID${APPLE_TEAM_ID} + + +EOF + +echo "==> Export" +xcodebuild -exportArchive \ + -archivePath "$BUILD_DIR/Happy.xcarchive" \ + -exportPath "$BUILD_DIR/export" \ + -exportOptionsPlist "$BUILD_DIR/ExportOptions.plist" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$P8_PATH" \ + -authenticationKeyID "$APPLE_ASC_KEY_ID" \ + -authenticationKeyIssuerID "$APPLE_ASC_ISSUER_ID" + +IPA=$(ls "$BUILD_DIR"/export/*.ipa | head -1) +echo "==> Upload $IPA" +xcrun altool --upload-app -f "$IPA" -t ios \ + --apiKey "$APPLE_ASC_KEY_ID" --apiIssuer "$APPLE_ASC_ISSUER_ID" + +echo "==> Done (build $HAPPY_BUILD_NUMBER)" +``` + +- [ ] **Step 3: Environment prereq checks** (before first run) + +```bash +which pod || echo "MISSING: CocoaPods (brew install cocoapods)" # known-missing on this machine (obs 8322) +ls ~/.appstoreconnect/private_keys/ 2>/dev/null || echo "MISSING: ASC key dir" +``` + +Install CocoaPods if missing. APPLE_* env values come from Infisical via the infisical-secrets skill — names only, never print values. + +- [ ] **Step 4: Commit (local branch only — never push to a PR)** + +```bash +git add packages/happy-app/app.config.js packages/happy-app/sources/sync/settings.ts scripts/build-ios-testflight.sh +git commit -m "local: TestFlight build config for ca.lixfeld.happy fork" +``` + +### Task 10: Manual prereqs + first build (interactive with user) + +- [ ] **Step 1 (USER, manual):** Register bundle ID `ca.lixfeld.happy` at developer.apple.com → Identifiers; create ASC app record (name "Happy JL" or similar) at appstoreconnect.apple.com. Capabilities: associated domains off (fork build drops `applinks` for production? No — config keeps it; harmless), push notifications NOT required (known-dead in fork). +- [ ] **Step 2:** Run `scripts/build-ios-testflight.sh` with env set. First run surfaces signing/entitlement issues — fix iteratively (debug signature: missing entitlement → check generated `ios/Happy/Happy.entitlements` vs portal capabilities; trim capabilities in app.config.js if the portal bundle lacks them, e.g. associated domains). +- [ ] **Step 3:** Confirm build appears in TestFlight (processing takes ~5–15 min), install on device, smoke test: pair, send text, attach gallery image / camera photo / PDF, verify agent sees each (CLI runs from local dist with feat/attachments merged — restart daemon on new dist first: `pnpm cli:install` flow used previously). + +--- + +## Self-review notes + +- Spec coverage: HEIC fix (T1–3), camera (T3–4), files (T3–4, T6), i18n (T5), CLI blocks (T6), flag flip + bundle + script (T9), prereqs/build/verify (T10). Branch strategy (T8). ✓ +- Existing-test risk: `runClaude.test.ts` mocks `drainAttachmentsForUserMessage` — unaffected. `settings.spec.ts` may assert `expImageUpload` default `false`; T9 flips it on local branch only — if that test breaks on local/testflight, update the assertion in the same local commit. +- The `document` content block must be verified against the installed @anthropic-ai/sdk version (T6 Step 4 covers it). +- expo-image-manipulator API shape verified at T2 Step 1 before use. From 1d877def27d4390bcb14b09db660ff962f709a20 Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:27:41 -0400 Subject: [PATCH 04/11] feat(app): image normalization decision logic for attachments Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../sources/utils/attachmentNormalize.spec.ts | 39 ++++++++++++++++++ .../sources/utils/attachmentNormalize.ts | 40 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 packages/happy-app/sources/utils/attachmentNormalize.spec.ts create mode 100644 packages/happy-app/sources/utils/attachmentNormalize.ts diff --git a/packages/happy-app/sources/utils/attachmentNormalize.spec.ts b/packages/happy-app/sources/utils/attachmentNormalize.spec.ts new file mode 100644 index 0000000000..62fdd17d9d --- /dev/null +++ b/packages/happy-app/sources/utils/attachmentNormalize.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { planImageNormalization, CLAUDE_VISION_MAX_EDGE } from './attachmentNormalize'; + +describe('planImageNormalization', () => { + it('passes through a small JPEG untouched', () => { + expect(planImageNormalization({ mimeType: 'image/jpeg', width: 800, height: 600 })) + .toEqual({ action: 'passthrough' }); + }); + + it('converts HEIC to JPEG', () => { + expect(planImageNormalization({ mimeType: 'image/heic', width: 800, height: 600 })) + .toEqual({ action: 'normalize', resize: undefined }); + }); + + it('downscales an oversized JPEG to 1568px long edge (landscape)', () => { + expect(planImageNormalization({ mimeType: 'image/jpeg', width: 4032, height: 3024 })) + .toEqual({ action: 'normalize', resize: { width: CLAUDE_VISION_MAX_EDGE } }); + }); + + it('downscales an oversized PNG to 1568px long edge (portrait)', () => { + expect(planImageNormalization({ mimeType: 'image/png', width: 3024, height: 4032 })) + .toEqual({ action: 'normalize', resize: { height: CLAUDE_VISION_MAX_EDGE } }); + }); + + it('passes through supported formats at exactly the ceiling', () => { + expect(planImageNormalization({ mimeType: 'image/webp', width: 1568, height: 1000 })) + .toEqual({ action: 'passthrough' }); + }); + + it('normalizes unknown/missing mime types defensively', () => { + expect(planImageNormalization({ mimeType: undefined, width: 800, height: 600 })) + .toEqual({ action: 'normalize', resize: undefined }); + }); + + it('treats zero dimensions as unknown size — converts format only', () => { + expect(planImageNormalization({ mimeType: 'image/heic', width: 0, height: 0 })) + .toEqual({ action: 'normalize', resize: undefined }); + }); +}); diff --git a/packages/happy-app/sources/utils/attachmentNormalize.ts b/packages/happy-app/sources/utils/attachmentNormalize.ts new file mode 100644 index 0000000000..94b5bbc30a --- /dev/null +++ b/packages/happy-app/sources/utils/attachmentNormalize.ts @@ -0,0 +1,40 @@ +/** + * Decides whether a picked image needs normalization before upload. + * + * The CLI converts attachments to Claude API image blocks by magic-byte + * sniffing and SKIPS anything that isn't JPEG/PNG/GIF/WebP — iOS HEIC would + * be silently dropped. The Claude vision API also downscales anything over + * 1568px long edge server-side and rejects images over 5MB, so uploading + * larger is pure waste. Pure function — tested in attachmentNormalize.spec.ts. + */ + +export const CLAUDE_VISION_MAX_EDGE = 1568; +export const NORMALIZE_JPEG_QUALITY = 0.9; + +const CLAUDE_SUPPORTED_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); + +export type NormalizationPlan = + | { action: 'passthrough' } + | { action: 'normalize'; resize: { width: number } | { height: number } | undefined }; + +export function planImageNormalization(input: { + mimeType: string | undefined; + width: number; + height: number; +}): NormalizationPlan { + const supported = input.mimeType !== undefined && CLAUDE_SUPPORTED_MIMES.has(input.mimeType); + const longEdge = Math.max(input.width, input.height); + const oversized = longEdge > CLAUDE_VISION_MAX_EDGE; + + if (supported && !oversized) { + return { action: 'passthrough' }; + } + + let resize: { width: number } | { height: number } | undefined = undefined; + if (oversized) { + resize = input.width >= input.height + ? { width: CLAUDE_VISION_MAX_EDGE } + : { height: CLAUDE_VISION_MAX_EDGE }; + } + return { action: 'normalize', resize }; +} From 31b7dd71efb041f6dcc73dcee458d61e04b264ea Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:28:12 -0400 Subject: [PATCH 05/11] feat(app): expo-image-manipulator normalization executor Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../sources/utils/attachmentNormalizeApply.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/happy-app/sources/utils/attachmentNormalizeApply.ts diff --git a/packages/happy-app/sources/utils/attachmentNormalizeApply.ts b/packages/happy-app/sources/utils/attachmentNormalizeApply.ts new file mode 100644 index 0000000000..860bad7fa8 --- /dev/null +++ b/packages/happy-app/sources/utils/attachmentNormalizeApply.ts @@ -0,0 +1,23 @@ +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'; +import { type NormalizationPlan, NORMALIZE_JPEG_QUALITY } from './attachmentNormalize'; + +/** + * Applies a normalization plan to an image URI. Returns the (possibly new) + * uri + dimensions + mime. Native + web (expo-image-manipulator supports both). + * Not unit-tested — exercised manually; the decision logic carries the tests + * (see attachmentNormalize.spec.ts). Lives in a separate file so vitest/node + * can import the pure logic without loading the native expo-image-manipulator module. + */ +export async function normalizeImage( + uri: string, + plan: NormalizationPlan, +): Promise<{ uri: string; width: number; height: number; mimeType: string } | null> { + if (plan.action === 'passthrough') return null; + const context = ImageManipulator.manipulate(uri); + if (plan.resize) { + context.resize(plan.resize); + } + const image = await context.renderAsync(); + const result = await image.saveAsync({ format: SaveFormat.JPEG, compress: NORMALIZE_JPEG_QUALITY }); + return { uri: result.uri, width: result.width, height: result.height, mimeType: 'image/jpeg' }; +} From 5fe2ea004253f52b59ca4d095ac3f4350126886f Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:30:37 -0400 Subject: [PATCH 06/11] fix(app): preserve PNG format/transparency when normalizing Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../sources/utils/attachmentNormalizeApply.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/happy-app/sources/utils/attachmentNormalizeApply.ts b/packages/happy-app/sources/utils/attachmentNormalizeApply.ts index 860bad7fa8..870a928623 100644 --- a/packages/happy-app/sources/utils/attachmentNormalizeApply.ts +++ b/packages/happy-app/sources/utils/attachmentNormalizeApply.ts @@ -11,6 +11,7 @@ import { type NormalizationPlan, NORMALIZE_JPEG_QUALITY } from './attachmentNorm export async function normalizeImage( uri: string, plan: NormalizationPlan, + originalMimeType: string | undefined, ): Promise<{ uri: string; width: number; height: number; mimeType: string } | null> { if (plan.action === 'passthrough') return null; const context = ImageManipulator.manipulate(uri); @@ -18,6 +19,13 @@ export async function normalizeImage( context.resize(plan.resize); } const image = await context.renderAsync(); - const result = await image.saveAsync({ format: SaveFormat.JPEG, compress: NORMALIZE_JPEG_QUALITY }); - return { uri: result.uri, width: result.width, height: result.height, mimeType: 'image/jpeg' }; + // PNGs stay PNG (lossless, keeps transparency — screenshots); everything + // else (HEIC, oversized JPEG, ...) re-encodes as JPEG. + const keepPng = originalMimeType === 'image/png'; + const result = await image.saveAsync( + keepPng + ? { format: SaveFormat.PNG } + : { format: SaveFormat.JPEG, compress: NORMALIZE_JPEG_QUALITY }, + ); + return { uri: result.uri, width: result.width, height: result.height, mimeType: keepPng ? 'image/png' : 'image/jpeg' }; } From 845c540a824134ea4132fede08212125fbc4e979 Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:34:31 -0400 Subject: [PATCH 07/11] feat(app): camera capture and file picking in attachment hook Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../happy-app/sources/hooks/useImagePicker.ts | 149 +++++++++++++++--- packages/happy-app/sources/text/_default.ts | 2 + .../happy-app/sources/text/translations/ca.ts | 2 + .../happy-app/sources/text/translations/en.ts | 2 + .../happy-app/sources/text/translations/es.ts | 2 + .../happy-app/sources/text/translations/it.ts | 2 + .../happy-app/sources/text/translations/ja.ts | 2 + .../happy-app/sources/text/translations/pl.ts | 2 + .../happy-app/sources/text/translations/pt.ts | 2 + .../happy-app/sources/text/translations/ru.ts | 2 + .../sources/text/translations/zh-Hans.ts | 2 + .../sources/text/translations/zh-Hant.ts | 2 + 12 files changed, 149 insertions(+), 22 deletions(-) diff --git a/packages/happy-app/sources/hooks/useImagePicker.ts b/packages/happy-app/sources/hooks/useImagePicker.ts index a39b0a58b0..9af497ee28 100644 --- a/packages/happy-app/sources/hooks/useImagePicker.ts +++ b/packages/happy-app/sources/hooks/useImagePicker.ts @@ -1,8 +1,13 @@ /** - * Image picker hook for attaching images to messages. + * Image picker hook for attaching images and files to messages. * - * Wraps expo-image-picker with permission handling and thumbhash generation. - * Enforces limits: max 20 images per message, 10MB per file. + * Exposes: + * - pickImages — gallery picker (expo-image-picker), with HEIC→JPEG and + * downscale normalization via attachmentNormalize.ts + * - takePhoto — camera capture (expo-image-picker), same normalization + * - pickFiles — document picker (expo-document-picker), no normalization + * + * Enforces limits: max 20 attachments per message, 10MB per file. * * Note: fileSize from expo-image-picker is optional — some platforms do not * provide it (returns undefined → size=0). Such files pass the client-side @@ -11,11 +16,14 @@ */ import { useState, useCallback, useRef, useEffect } from 'react'; import * as ImagePicker from 'expo-image-picker'; +import * as DocumentPicker from 'expo-document-picker'; import { Platform } from 'react-native'; import { Modal } from '@/modal'; import { generateThumbhash } from '@/utils/thumbhash'; import { t } from '@/text'; import type { AttachmentPreview } from '@/sync/attachmentTypes'; +import { planImageNormalization } from '@/utils/attachmentNormalize'; +import { normalizeImage } from '@/utils/attachmentNormalizeApply'; export const MAX_IMAGES_PER_MESSAGE = 20; export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -25,6 +33,8 @@ export type { AttachmentPreview }; type UseImagePickerResult = { selectedImages: AttachmentPreview[]; pickImages: () => Promise; + takePhoto: () => Promise; + pickFiles: () => Promise; removeImage: (id: string) => void; clearImages: () => void; addImages: (images: AttachmentPreview[]) => void; @@ -53,6 +63,52 @@ export function useImagePicker(): UseImagePickerResult { return true; }, []); + // Shared by gallery + camera: enforce size cap, normalize (HEIC→JPEG, + // downscale >1568px — see attachmentNormalize.ts), generate thumbhash. + const assetsToPreviews = useCallback(async (assets: ImagePicker.ImagePickerAsset[]): Promise => { + const previews: AttachmentPreview[] = []; + for (const asset of assets) { + let { uri, width, height } = asset; + let mimeType = asset.mimeType ?? undefined; + let size = asset.fileSize ?? 0; + + const plan = planImageNormalization({ mimeType, width, height }); + const normalized = await normalizeImage(uri, plan, mimeType).catch(() => null); + if (normalized) { + uri = normalized.uri; + width = normalized.width; + height = normalized.height; + mimeType = normalized.mimeType; + size = 0; // unknown after re-encode; server enforces the cap + } + + if (size > MAX_FILE_SIZE) { + Modal.alert( + t('imageUpload.fileTooLargeTitle'), + t('imageUpload.fileTooLargeMessage', { name: asset.fileName ?? 'image', maxMb: 10 }), + [{ text: t('common.ok') }], + ); + continue; + } + + const thumbhash = (width > 0 && height > 0) + ? await generateThumbhash(uri, width, height) + : undefined; + + previews.push({ + id: `${Date.now()}_${Math.random().toString(36).slice(2)}`, + uri, + width, + height, + mimeType: mimeType ?? 'image/jpeg', + size, + name: asset.fileName ?? `image_${Date.now()}.jpg`, + thumbhash, + }); + } + return previews; + }, []); + const pickImages = useCallback(async () => { const hasPermission = await requestPermission(); if (!hasPermission) return; @@ -71,7 +127,7 @@ export function useImagePicker(): UseImagePickerResult { mediaTypes: ['images'], // expo-image-picker ~55: MediaTypeOptions deprecated allowsMultipleSelection: true, selectionLimit: remaining, - quality: 1, // no recompression — preserve original for Claude + quality: 1, // normalization handles size/format downstream exif: false, }); @@ -79,41 +135,90 @@ export function useImagePicker(): UseImagePickerResult { // On web, selectionLimit is not enforced by the browser — clamp here. const assets = result.assets.slice(0, remaining); - const previews: AttachmentPreview[] = []; + const previews = await assetsToPreviews(assets); - for (const asset of assets) { - const size = asset.fileSize ?? 0; + if (previews.length > 0) { + setSelectedImages(prev => [...prev, ...previews].slice(0, MAX_IMAGES_PER_MESSAGE)); + } + }, [requestPermission, assetsToPreviews]); + + const takePhoto = useCallback(async () => { + if (Platform.OS !== 'web') { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== 'granted') { + Modal.alert( + t('imageUpload.cameraPermissionTitle'), + t('imageUpload.cameraPermissionMessage'), + [{ text: t('common.ok') }], + ); + return; + } + } + if (MAX_IMAGES_PER_MESSAGE - selectedCountRef.current <= 0) { + Modal.alert( + t('imageUpload.limitTitle'), + t('imageUpload.limitMessage', { max: MAX_IMAGES_PER_MESSAGE }), + [{ text: t('common.ok') }], + ); + return; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + quality: 1, // normalization handles size/format downstream + exif: false, + }); + if (result.canceled || !result.assets.length) return; + + const previews = await assetsToPreviews(result.assets); + if (previews.length > 0) { + setSelectedImages(prev => [...prev, ...previews].slice(0, MAX_IMAGES_PER_MESSAGE)); + } + }, [assetsToPreviews]); + + const pickFiles = useCallback(async () => { + const remaining = MAX_IMAGES_PER_MESSAGE - selectedCountRef.current; + if (remaining <= 0) { + Modal.alert( + t('imageUpload.limitTitle'), + t('imageUpload.limitMessage', { max: MAX_IMAGES_PER_MESSAGE }), + [{ text: t('common.ok') }], + ); + return; + } + + const result = await DocumentPicker.getDocumentAsync({ + multiple: true, + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets) return; + const previews: AttachmentPreview[] = []; + for (const asset of result.assets.slice(0, remaining)) { + const size = asset.size ?? 0; if (size > MAX_FILE_SIZE) { Modal.alert( t('imageUpload.fileTooLargeTitle'), - t('imageUpload.fileTooLargeMessage', { name: asset.fileName ?? 'image', maxMb: 10 }), + t('imageUpload.fileTooLargeMessage', { name: asset.name, maxMb: 10 }), [{ text: t('common.ok') }], ); continue; } - - // Skip thumbhash if dimensions are unavailable (prevents divide-by-zero). - const thumbhash = (asset.width > 0 && asset.height > 0) - ? await generateThumbhash(asset.uri, asset.width, asset.height) - : undefined; - previews.push({ id: `${Date.now()}_${Math.random().toString(36).slice(2)}`, uri: asset.uri, - width: asset.width, - height: asset.height, - mimeType: asset.mimeType ?? 'image/jpeg', + width: 0, + height: 0, + mimeType: asset.mimeType ?? 'application/octet-stream', size, - name: asset.fileName ?? `image_${Date.now()}.jpg`, - thumbhash, + name: asset.name, + thumbhash: undefined, }); } - if (previews.length > 0) { setSelectedImages(prev => [...prev, ...previews].slice(0, MAX_IMAGES_PER_MESSAGE)); } - }, [requestPermission]); + }, []); const removeImage = useCallback((id: string) => { setSelectedImages(prev => prev.filter(img => img.id !== id)); @@ -131,5 +236,5 @@ export function useImagePicker(): UseImagePickerResult { }); }, []); - return { selectedImages, pickImages, removeImage, clearImages, addImages }; + return { selectedImages, pickImages, takePhoto, pickFiles, removeImage, clearImages, addImages }; } diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index 911b469c0c..fdae0be97d 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -223,6 +223,8 @@ export const en = { imageUpload: { permissionTitle: 'Photo Library Access', permissionMessage: 'Allow access to your photo library to attach images to messages.', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: 'Image Limit Reached', limitMessage: ({ max }: { max: number }) => `You can attach up to ${max} images per message.`, fileTooLargeTitle: 'File Too Large', diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 9fc501b15b..30ad088292 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -971,6 +971,8 @@ export const ca: TranslationStructure = { imageUpload: { permissionTitle: 'Accés a la biblioteca de fotos', permissionMessage: "Permet l'accés a la teva biblioteca de fotos per adjuntar imatges als missatges.", + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: "Límit d'imatges assolit", limitMessage: ({ max }: { max: number }) => `Pots adjuntar fins a ${max} imatges per missatge.`, fileTooLargeTitle: 'Fitxer massa gran', diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index 85b5de6318..8fd2a784ce 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -986,6 +986,8 @@ export const en: TranslationStructure = { imageUpload: { permissionTitle: 'Photo Library Access', permissionMessage: 'Allow access to your photo library to attach images to messages.', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: 'Image Limit Reached', limitMessage: ({ max }: { max: number }) => `You can attach up to ${max} images per message.`, fileTooLargeTitle: 'File Too Large', diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 120051f627..52887a7a99 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -972,6 +972,8 @@ export const es: TranslationStructure = { imageUpload: { permissionTitle: 'Acceso a la biblioteca de fotos', permissionMessage: 'Permite el acceso a tu biblioteca de fotos para adjuntar imágenes a los mensajes.', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: 'Límite de imágenes alcanzado', limitMessage: ({ max }: { max: number }) => `Puedes adjuntar hasta ${max} imágenes por mensaje.`, fileTooLargeTitle: 'Archivo demasiado grande', diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 2d6a9b1862..1e864b2e04 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -970,6 +970,8 @@ export const it: TranslationStructure = { imageUpload: { permissionTitle: 'Accesso alla libreria foto', permissionMessage: "Consenti l'accesso alla tua libreria foto per allegare immagini ai messaggi.", + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: 'Limite immagini raggiunto', limitMessage: ({ max }: { max: number }) => `Puoi allegare fino a ${max} immagini per messaggio.`, fileTooLargeTitle: 'File troppo grande', diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 0c912637e6..60a1845cdf 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -973,6 +973,8 @@ export const ja: TranslationStructure = { imageUpload: { permissionTitle: 'フォトライブラリへのアクセス', permissionMessage: 'メッセージに画像を添付するには、フォトライブラリへのアクセスを許可してください。', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: '画像の上限に達しました', limitMessage: ({ max }: { max: number }) => `1メッセージに添付できる画像は最大${max}枚です。`, fileTooLargeTitle: 'ファイルが大きすぎます', diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index a6e36bac7f..44204c1a56 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -1001,6 +1001,8 @@ export const pl: TranslationStructure = { imageUpload: { permissionTitle: 'Dostęp do biblioteki zdjęć', permissionMessage: 'Zezwól na dostęp do biblioteki zdjęć, aby załączać obrazy do wiadomości.', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: 'Osiągnięto limit obrazów', limitMessage: ({ max }: { max: number }) => `Możesz dołączyć maksymalnie ${max} obrazów na wiadomość.`, fileTooLargeTitle: 'Plik zbyt duży', diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index 69a207a1bb..c1e0247b67 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -970,6 +970,8 @@ export const pt: TranslationStructure = { imageUpload: { permissionTitle: 'Acesso à biblioteca de fotos', permissionMessage: 'Permita o acesso à sua biblioteca de fotos para anexar imagens às mensagens.', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: 'Limite de imagens atingido', limitMessage: ({ max }: { max: number }) => `Você pode anexar até ${max} imagens por mensagem.`, fileTooLargeTitle: 'Arquivo muito grande', diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index 5b813a8d7d..f73dc554f9 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -1000,6 +1000,8 @@ export const ru: TranslationStructure = { imageUpload: { permissionTitle: 'Доступ к библиотеке фото', permissionMessage: 'Разрешите доступ к вашей библиотеке фото, чтобы прикреплять изображения к сообщениям.', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: 'Достигнут лимит изображений', limitMessage: ({ max }: { max: number }) => `Можно прикрепить не более ${max} изображений на сообщение.`, fileTooLargeTitle: 'Файл слишком большой', diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index 4d7c4261bc..f71abdd4fc 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -972,6 +972,8 @@ export const zhHans: TranslationStructure = { imageUpload: { permissionTitle: '访问照片库', permissionMessage: '允许访问您的照片库以在消息中附加图片。', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: '已达到图片限制', limitMessage: ({ max }: { max: number }) => `每条消息最多可附加 ${max} 张图片。`, fileTooLargeTitle: '文件过大', diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index 939b666ee1..07689ff098 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -971,6 +971,8 @@ export const zhHant: TranslationStructure = { imageUpload: { permissionTitle: '存取照片圖庫', permissionMessage: '允許存取您的照片圖庫以在訊息中附加圖片。', + cameraPermissionTitle: 'Camera Access', + cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', limitTitle: '已達到圖片限制', limitMessage: ({ max }: { max: number }) => `每則訊息最多可附加 ${max} 張圖片。`, fileTooLargeTitle: '檔案太大', From 8d90c77a852b5a5d698d76ec12025e04517753c4 Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:40:11 -0400 Subject: [PATCH 08/11] feat(app): attachment source chooser (library/camera/files) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- packages/happy-app/app.config.js | 1 + .../happy-app/sources/-session/SessionView.tsx | 16 ++++++++++++++-- packages/happy-app/sources/text/_default.ts | 4 ++++ .../happy-app/sources/text/translations/ca.ts | 4 ++++ .../happy-app/sources/text/translations/en.ts | 4 ++++ .../happy-app/sources/text/translations/es.ts | 4 ++++ .../happy-app/sources/text/translations/it.ts | 4 ++++ .../happy-app/sources/text/translations/ja.ts | 4 ++++ .../happy-app/sources/text/translations/pl.ts | 4 ++++ .../happy-app/sources/text/translations/pt.ts | 4 ++++ .../happy-app/sources/text/translations/ru.ts | 4 ++++ .../sources/text/translations/zh-Hans.ts | 4 ++++ .../sources/text/translations/zh-Hant.ts | 4 ++++ 13 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/happy-app/app.config.js b/packages/happy-app/app.config.js index e366752507..0e615bd454 100644 --- a/packages/happy-app/app.config.js +++ b/packages/happy-app/app.config.js @@ -73,6 +73,7 @@ export default { }, infoPlist: { NSMicrophoneUsageDescription: "Allow $(PRODUCT_NAME) to access your microphone for voice conversations with AI.", + NSCameraUsageDescription: "Allow $(PRODUCT_NAME) to use the camera to take photos to attach to messages.", NSLocalNetworkUsageDescription: "Allow $(PRODUCT_NAME) to find and connect to local devices on your network.", NSBonjourServices: ["_http._tcp", "_https._tcp"], // ATS: diff --git a/packages/happy-app/sources/-session/SessionView.tsx b/packages/happy-app/sources/-session/SessionView.tsx index 264dd6beb4..b16df8cd4a 100644 --- a/packages/happy-app/sources/-session/SessionView.tsx +++ b/packages/happy-app/sources/-session/SessionView.tsx @@ -477,7 +477,19 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: // Image attachment state (expImageUpload feature flag) const expImageUpload = useSetting('expImageUpload'); - const { selectedImages, pickImages, removeImage, clearImages, addImages } = useImagePicker(); + const { selectedImages, pickImages, takePhoto, pickFiles, removeImage, clearImages, addImages } = useImagePicker(); + const handlePickAttachment = React.useCallback(() => { + if (Platform.OS === 'web') { + pickImages(); + return; + } + Modal.alert(t('imageUpload.addTitle'), undefined, [ + { text: t('imageUpload.optionLibrary'), onPress: () => { pickImages(); } }, + { text: t('imageUpload.optionCamera'), onPress: () => { takePhoto(); } }, + { text: t('imageUpload.optionFiles'), onPress: () => { pickFiles(); } }, + { text: t('common.cancel'), style: 'cancel' }, + ]); + }, [pickImages, takePhoto, pickFiles]); // ChatComposer owns the message state + useDraft subscription. We only // hold an imperative handle so handleSend can read the live text and @@ -678,7 +690,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: showAbortButton={sessionStatus.state === 'thinking' || sessionStatus.state === 'waiting'} onFileViewerPress={experiments && !isTablet ? handleFileViewerPress : undefined} selectedImages={expImageUpload ? selectedImages : undefined} - onPickImages={expImageUpload ? pickImages : undefined} + onPickImages={expImageUpload ? handlePickAttachment : undefined} onRemoveImage={expImageUpload ? removeImage : undefined} onAddImages={expImageUpload ? addImages : undefined} autocompletePrefixes={AGENT_INPUT_AUTOCOMPLETE_PREFIXES} diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index fdae0be97d..54883d0b8b 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -235,6 +235,10 @@ export const en = { : `${count} images could not be uploaded and were not sent.`, notSupportedTitle: 'Images Not Supported', notSupportedMessage: 'This agent does not support image attachments. Only the text was sent.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, errors: { diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 30ad088292..1d091066f3 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -983,6 +983,10 @@ export const ca: TranslationStructure = { : `No s'han pogut pujar ${count} imatges i no s'han enviat.`, notSupportedTitle: 'Imatges no compatibles', notSupportedMessage: 'Aquest agent no admet imatges adjuntes. Només s\'ha enviat el text.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index 8fd2a784ce..6e6fca0623 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -998,6 +998,10 @@ export const en: TranslationStructure = { : `${count} images could not be uploaded and were not sent.`, notSupportedTitle: 'Images Not Supported', notSupportedMessage: 'This agent does not support image attachments. Only the text was sent.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 52887a7a99..31120d4721 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -984,6 +984,10 @@ export const es: TranslationStructure = { : `No se pudieron subir ${count} imágenes y no se enviaron.`, notSupportedTitle: 'Imágenes no compatibles', notSupportedMessage: 'Este agente no admite imágenes adjuntas. Solo se envió el texto.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 1e864b2e04..1d46913440 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -982,6 +982,10 @@ export const it: TranslationStructure = { : `Non è stato possibile caricare ${count} immagini e non sono state inviate.`, notSupportedTitle: 'Immagini non supportate', notSupportedMessage: 'Questo agente non supporta gli allegati immagine. È stato inviato solo il testo.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 60a1845cdf..061bd8b169 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -985,6 +985,10 @@ export const ja: TranslationStructure = { : `${count}枚の画像をアップロードできず、送信されませんでした。`, notSupportedTitle: '画像はサポートされていません', notSupportedMessage: 'このエージェントは画像の添付に対応していません。テキストのみが送信されました。', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index 44204c1a56..b5e1338ad1 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -1013,6 +1013,10 @@ export const pl: TranslationStructure = { : `Nie udało się przesłać ${count} zdjęć i nie zostały wysłane.`, notSupportedTitle: 'Obrazy nieobsługiwane', notSupportedMessage: 'Ten agent nie obsługuje załączników obrazów. Wysłano tylko tekst.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index c1e0247b67..ac96133d6e 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -982,6 +982,10 @@ export const pt: TranslationStructure = { : `Não foi possível enviar ${count} imagens e não foram enviadas.`, notSupportedTitle: 'Imagens não suportadas', notSupportedMessage: 'Este agente não suporta anexos de imagem. Apenas o texto foi enviado.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index f73dc554f9..20f09e9729 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -1012,6 +1012,10 @@ export const ru: TranslationStructure = { : `${count} изображений не удалось загрузить — они не были отправлены.`, notSupportedTitle: 'Изображения не поддерживаются', notSupportedMessage: 'Этот агент не поддерживает изображения. Отправлен только текст.', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index f71abdd4fc..2f99fba66f 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -984,6 +984,10 @@ export const zhHans: TranslationStructure = { : `${count} 张图片上传失败,未发送。`, notSupportedTitle: '不支持图片', notSupportedMessage: '该代理不支持图片附件。仅发送了文本。', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index 07689ff098..c313a79a2c 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -983,6 +983,10 @@ export const zhHant: TranslationStructure = { : `${count} 張圖片上傳失敗,未傳送。`, notSupportedTitle: '不支援圖片', notSupportedMessage: '此代理不支援圖片附件。僅傳送了文字。', + addTitle: 'Add Attachment', + optionLibrary: 'Photo Library', + optionCamera: 'Take Photo', + optionFiles: 'Choose File', }, feed: { From b8720c2321800c92e2bc89420ff28b642e1f0114 Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:44:51 -0400 Subject: [PATCH 09/11] feat(app): translate attachment chooser strings across all locales Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- packages/happy-app/sources/text/_default.ts | 4 ++-- .../happy-app/sources/text/translations/ca.ts | 16 ++++++++-------- .../happy-app/sources/text/translations/en.ts | 4 ++-- .../happy-app/sources/text/translations/es.ts | 16 ++++++++-------- .../happy-app/sources/text/translations/it.ts | 16 ++++++++-------- .../happy-app/sources/text/translations/ja.ts | 16 ++++++++-------- .../happy-app/sources/text/translations/pl.ts | 16 ++++++++-------- .../happy-app/sources/text/translations/pt.ts | 16 ++++++++-------- .../happy-app/sources/text/translations/ru.ts | 16 ++++++++-------- .../sources/text/translations/zh-Hans.ts | 16 ++++++++-------- .../sources/text/translations/zh-Hant.ts | 16 ++++++++-------- 11 files changed, 76 insertions(+), 76 deletions(-) diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index 54883d0b8b..d3e4e8c4cb 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -216,8 +216,8 @@ export const en = { disableAnalytics: 'Disable Analytics', analyticsDisabled: 'All tracking and telemetry disabled', analyticsEnabled: 'Anonymous usage analytics active', - imageUpload: 'Image Upload', - imageUploadSubtitle: 'Attach images to messages for Claude to analyze', + imageUpload: 'Attachments', + imageUploadSubtitle: 'Attach images and files to messages for Claude to analyze', }, imageUpload: { diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 1d091066f3..c92ad7593e 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -218,8 +218,8 @@ export const ca: TranslationStructure = { disableAnalytics: 'Desactivar analítica', analyticsDisabled: 'Tot el seguiment i telemetria desactivats', analyticsEnabled: 'Analítica anònima d\'ús activa', - imageUpload: 'Pujada d\'imatges', - imageUploadSubtitle: 'Adjunta imatges als missatges perquè Claude les analitzi', + imageUpload: 'Fitxers adjunts', + imageUploadSubtitle: 'Adjunta imatges i fitxers als missatges perquè Claude els analitzi', }, errors: { @@ -971,8 +971,8 @@ export const ca: TranslationStructure = { imageUpload: { permissionTitle: 'Accés a la biblioteca de fotos', permissionMessage: "Permet l'accés a la teva biblioteca de fotos per adjuntar imatges als missatges.", - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: 'Accés a la càmera', + cameraPermissionMessage: "Permet l'accés a la càmera per fer fotos i adjuntar-les als missatges.", limitTitle: "Límit d'imatges assolit", limitMessage: ({ max }: { max: number }) => `Pots adjuntar fins a ${max} imatges per missatge.`, fileTooLargeTitle: 'Fitxer massa gran', @@ -983,10 +983,10 @@ export const ca: TranslationStructure = { : `No s'han pogut pujar ${count} imatges i no s'han enviat.`, notSupportedTitle: 'Imatges no compatibles', notSupportedMessage: 'Aquest agent no admet imatges adjuntes. Només s\'ha enviat el text.', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: 'Afegeix un fitxer adjunt', + optionLibrary: 'Fototeca', + optionCamera: 'Fes una foto', + optionFiles: 'Tria un fitxer', }, feed: { diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index 6e6fca0623..531333ca61 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -232,8 +232,8 @@ export const en: TranslationStructure = { disableAnalytics: 'Disable Analytics', analyticsDisabled: 'All tracking and telemetry disabled', analyticsEnabled: 'Anonymous usage analytics active', - imageUpload: 'Image Upload', - imageUploadSubtitle: 'Attach images to messages for Claude to analyze', + imageUpload: 'Attachments', + imageUploadSubtitle: 'Attach images and files to messages for Claude to analyze', }, errors: { diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 31120d4721..33813382b1 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -218,8 +218,8 @@ export const es: TranslationStructure = { disableAnalytics: 'Desactivar analítica', analyticsDisabled: 'Todo el seguimiento y telemetría desactivados', analyticsEnabled: 'Analítica anónima de uso activa', - imageUpload: 'Subida de imágenes', - imageUploadSubtitle: 'Adjunta imágenes a los mensajes para que Claude las analice', + imageUpload: 'Adjuntos', + imageUploadSubtitle: 'Adjunta imágenes y archivos a los mensajes para que Claude los analice', }, errors: { @@ -972,8 +972,8 @@ export const es: TranslationStructure = { imageUpload: { permissionTitle: 'Acceso a la biblioteca de fotos', permissionMessage: 'Permite el acceso a tu biblioteca de fotos para adjuntar imágenes a los mensajes.', - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: 'Acceso a la cámara', + cameraPermissionMessage: 'Permite el acceso a la cámara para tomar fotos y adjuntarlas a los mensajes.', limitTitle: 'Límite de imágenes alcanzado', limitMessage: ({ max }: { max: number }) => `Puedes adjuntar hasta ${max} imágenes por mensaje.`, fileTooLargeTitle: 'Archivo demasiado grande', @@ -984,10 +984,10 @@ export const es: TranslationStructure = { : `No se pudieron subir ${count} imágenes y no se enviaron.`, notSupportedTitle: 'Imágenes no compatibles', notSupportedMessage: 'Este agente no admite imágenes adjuntas. Solo se envió el texto.', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: 'Añadir adjunto', + optionLibrary: 'Fototeca', + optionCamera: 'Tomar foto', + optionFiles: 'Elegir archivo', }, feed: { diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 1d46913440..642a000c94 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -216,8 +216,8 @@ export const it: TranslationStructure = { disableAnalytics: 'Disabilita analisi', analyticsDisabled: 'Tutto il tracciamento e la telemetria disabilitati', analyticsEnabled: 'Analisi anonime di utilizzo attive', - imageUpload: 'Caricamento immagini', - imageUploadSubtitle: 'Allega immagini ai messaggi per farle analizzare da Claude', + imageUpload: 'Allegati', + imageUploadSubtitle: 'Allega immagini e file ai messaggi per l\'analisi di Claude', }, errors: { @@ -970,8 +970,8 @@ export const it: TranslationStructure = { imageUpload: { permissionTitle: 'Accesso alla libreria foto', permissionMessage: "Consenti l'accesso alla tua libreria foto per allegare immagini ai messaggi.", - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: 'Accesso alla fotocamera', + cameraPermissionMessage: "Consenti l'accesso alla fotocamera per scattare foto da allegare ai messaggi.", limitTitle: 'Limite immagini raggiunto', limitMessage: ({ max }: { max: number }) => `Puoi allegare fino a ${max} immagini per messaggio.`, fileTooLargeTitle: 'File troppo grande', @@ -982,10 +982,10 @@ export const it: TranslationStructure = { : `Non è stato possibile caricare ${count} immagini e non sono state inviate.`, notSupportedTitle: 'Immagini non supportate', notSupportedMessage: 'Questo agente non supporta gli allegati immagine. È stato inviato solo il testo.', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: 'Aggiungi allegato', + optionLibrary: 'Libreria foto', + optionCamera: 'Scatta foto', + optionFiles: 'Scegli file', }, feed: { diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 061bd8b169..9ac8606e46 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -219,8 +219,8 @@ export const ja: TranslationStructure = { disableAnalytics: '分析を無効化', analyticsDisabled: 'すべてのトラッキングとテレメトリが無効', analyticsEnabled: '匿名の使用状況分析がアクティブ', - imageUpload: '画像アップロード', - imageUploadSubtitle: 'メッセージに画像を添付してClaudeに分析させる', + imageUpload: '添付ファイル', + imageUploadSubtitle: '画像やファイルをメッセージに添付して Claude に分析させる', }, errors: { @@ -973,8 +973,8 @@ export const ja: TranslationStructure = { imageUpload: { permissionTitle: 'フォトライブラリへのアクセス', permissionMessage: 'メッセージに画像を添付するには、フォトライブラリへのアクセスを許可してください。', - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: 'カメラへのアクセス', + cameraPermissionMessage: 'メッセージに添付する写真を撮るには、カメラへのアクセスを許可してください。', limitTitle: '画像の上限に達しました', limitMessage: ({ max }: { max: number }) => `1メッセージに添付できる画像は最大${max}枚です。`, fileTooLargeTitle: 'ファイルが大きすぎます', @@ -985,10 +985,10 @@ export const ja: TranslationStructure = { : `${count}枚の画像をアップロードできず、送信されませんでした。`, notSupportedTitle: '画像はサポートされていません', notSupportedMessage: 'このエージェントは画像の添付に対応していません。テキストのみが送信されました。', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: '添付ファイルを追加', + optionLibrary: 'フォトライブラリ', + optionCamera: '写真を撮る', + optionFiles: 'ファイルを選択', }, feed: { diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index b5e1338ad1..bcbe71a6f5 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -235,8 +235,8 @@ export const pl: TranslationStructure = { disableAnalytics: 'Wyłącz analitykę', analyticsDisabled: 'Wszystkie śledzenie i telemetria wyłączone', analyticsEnabled: 'Anonimowa analityka użytkowania aktywna', - imageUpload: 'Przesyłanie obrazów', - imageUploadSubtitle: 'Dołącz obrazy do wiadomości, aby Claude mógł je przeanalizować', + imageUpload: 'Załączniki', + imageUploadSubtitle: 'Dołączaj obrazy i pliki do wiadomości do analizy przez Claude', }, errors: { @@ -1001,8 +1001,8 @@ export const pl: TranslationStructure = { imageUpload: { permissionTitle: 'Dostęp do biblioteki zdjęć', permissionMessage: 'Zezwól na dostęp do biblioteki zdjęć, aby załączać obrazy do wiadomości.', - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: 'Dostęp do aparatu', + cameraPermissionMessage: 'Zezwól na dostęp do aparatu, aby robić zdjęcia i dołączać je do wiadomości.', limitTitle: 'Osiągnięto limit obrazów', limitMessage: ({ max }: { max: number }) => `Możesz dołączyć maksymalnie ${max} obrazów na wiadomość.`, fileTooLargeTitle: 'Plik zbyt duży', @@ -1013,10 +1013,10 @@ export const pl: TranslationStructure = { : `Nie udało się przesłać ${count} zdjęć i nie zostały wysłane.`, notSupportedTitle: 'Obrazy nieobsługiwane', notSupportedMessage: 'Ten agent nie obsługuje załączników obrazów. Wysłano tylko tekst.', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: 'Dodaj załącznik', + optionLibrary: 'Biblioteka zdjęć', + optionCamera: 'Zrób zdjęcie', + optionFiles: 'Wybierz plik', }, feed: { diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index ac96133d6e..96f2911755 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -217,8 +217,8 @@ export const pt: TranslationStructure = { disableAnalytics: 'Desativar análises', analyticsDisabled: 'Todo rastreamento e telemetria desativados', analyticsEnabled: 'Análises anônimas de uso ativas', - imageUpload: 'Upload de imagens', - imageUploadSubtitle: 'Anexe imagens às mensagens para Claude analisar', + imageUpload: 'Anexos', + imageUploadSubtitle: 'Anexe imagens e arquivos às mensagens para o Claude analisar', }, errors: { @@ -970,8 +970,8 @@ export const pt: TranslationStructure = { imageUpload: { permissionTitle: 'Acesso à biblioteca de fotos', permissionMessage: 'Permita o acesso à sua biblioteca de fotos para anexar imagens às mensagens.', - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: 'Acesso à câmera', + cameraPermissionMessage: 'Permita o acesso à câmera para tirar fotos e anexá-las às mensagens.', limitTitle: 'Limite de imagens atingido', limitMessage: ({ max }: { max: number }) => `Você pode anexar até ${max} imagens por mensagem.`, fileTooLargeTitle: 'Arquivo muito grande', @@ -982,10 +982,10 @@ export const pt: TranslationStructure = { : `Não foi possível enviar ${count} imagens e não foram enviadas.`, notSupportedTitle: 'Imagens não suportadas', notSupportedMessage: 'Este agente não suporta anexos de imagem. Apenas o texto foi enviado.', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: 'Adicionar anexo', + optionLibrary: 'Biblioteca de fotos', + optionCamera: 'Tirar foto', + optionFiles: 'Escolher arquivo', }, feed: { diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index 20f09e9729..c61b6f34ec 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -204,8 +204,8 @@ export const ru: TranslationStructure = { disableAnalytics: 'Отключить аналитику', analyticsDisabled: 'Вся аналитика и телеметрия отключены', analyticsEnabled: 'Анонимная аналитика использования активна', - imageUpload: 'Загрузка изображений', - imageUploadSubtitle: 'Прикрепляйте изображения к сообщениям для анализа Claude', + imageUpload: 'Вложения', + imageUploadSubtitle: 'Прикрепляйте изображения и файлы к сообщениям для анализа Claude', }, errors: { @@ -1000,8 +1000,8 @@ export const ru: TranslationStructure = { imageUpload: { permissionTitle: 'Доступ к библиотеке фото', permissionMessage: 'Разрешите доступ к вашей библиотеке фото, чтобы прикреплять изображения к сообщениям.', - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: 'Доступ к камере', + cameraPermissionMessage: 'Разрешите доступ к камере, чтобы делать фото и прикреплять их к сообщениям.', limitTitle: 'Достигнут лимит изображений', limitMessage: ({ max }: { max: number }) => `Можно прикрепить не более ${max} изображений на сообщение.`, fileTooLargeTitle: 'Файл слишком большой', @@ -1012,10 +1012,10 @@ export const ru: TranslationStructure = { : `${count} изображений не удалось загрузить — они не были отправлены.`, notSupportedTitle: 'Изображения не поддерживаются', notSupportedMessage: 'Этот агент не поддерживает изображения. Отправлен только текст.', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: 'Добавить вложение', + optionLibrary: 'Фототека', + optionCamera: 'Сделать фото', + optionFiles: 'Выбрать файл', }, feed: { diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index 2f99fba66f..1ac35f1147 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -219,8 +219,8 @@ export const zhHans: TranslationStructure = { disableAnalytics: '禁用分析', analyticsDisabled: '所有跟踪和遥测已禁用', analyticsEnabled: '匿名使用分析已启用', - imageUpload: '图片上传', - imageUploadSubtitle: '将图片附加到消息中让 Claude 分析', + imageUpload: '附件', + imageUploadSubtitle: '在消息中附加图片和文件供 Claude 分析', }, errors: { @@ -972,8 +972,8 @@ export const zhHans: TranslationStructure = { imageUpload: { permissionTitle: '访问照片库', permissionMessage: '允许访问您的照片库以在消息中附加图片。', - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: '相机权限', + cameraPermissionMessage: '允许访问相机以拍摄照片并附加到消息中。', limitTitle: '已达到图片限制', limitMessage: ({ max }: { max: number }) => `每条消息最多可附加 ${max} 张图片。`, fileTooLargeTitle: '文件过大', @@ -984,10 +984,10 @@ export const zhHans: TranslationStructure = { : `${count} 张图片上传失败,未发送。`, notSupportedTitle: '不支持图片', notSupportedMessage: '该代理不支持图片附件。仅发送了文本。', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: '添加附件', + optionLibrary: '照片图库', + optionCamera: '拍照', + optionFiles: '选择文件', }, feed: { diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index c313a79a2c..d162853498 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -218,8 +218,8 @@ export const zhHant: TranslationStructure = { disableAnalytics: '停用分析', analyticsDisabled: '所有追蹤和遙測已停用', analyticsEnabled: '匿名使用分析已啟用', - imageUpload: '圖片上傳', - imageUploadSubtitle: '將圖片附加到訊息中讓 Claude 分析', + imageUpload: '附件', + imageUploadSubtitle: '在訊息中附加圖片和檔案供 Claude 分析', }, errors: { @@ -971,8 +971,8 @@ export const zhHant: TranslationStructure = { imageUpload: { permissionTitle: '存取照片圖庫', permissionMessage: '允許存取您的照片圖庫以在訊息中附加圖片。', - cameraPermissionTitle: 'Camera Access', - cameraPermissionMessage: 'Allow camera access to take photos to attach to messages.', + cameraPermissionTitle: '相機權限', + cameraPermissionMessage: '允許存取相機以拍攝照片並附加到訊息中。', limitTitle: '已達到圖片限制', limitMessage: ({ max }: { max: number }) => `每則訊息最多可附加 ${max} 張圖片。`, fileTooLargeTitle: '檔案太大', @@ -983,10 +983,10 @@ export const zhHant: TranslationStructure = { : `${count} 張圖片上傳失敗,未傳送。`, notSupportedTitle: '不支援圖片', notSupportedMessage: '此代理不支援圖片附件。僅傳送了文字。', - addTitle: 'Add Attachment', - optionLibrary: 'Photo Library', - optionCamera: 'Take Photo', - optionFiles: 'Choose File', + addTitle: '新增附件', + optionLibrary: '照片圖庫', + optionCamera: '拍照', + optionFiles: '選擇檔案', }, feed: { From 080ccbd1c1421339a8de01c7a753d948d15c58e7 Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 07:53:21 -0400 Subject: [PATCH 10/11] feat(cli): convert PDF and text attachments to Claude content blocks Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../src/claude/claudeRemoteLauncher.ts | 58 +--------- .../utils/attachmentContentBlocks.test.ts | 60 ++++++++++ .../claude/utils/attachmentContentBlocks.ts | 107 ++++++++++++++++++ 3 files changed, 171 insertions(+), 54 deletions(-) create mode 100644 packages/happy-cli/src/claude/utils/attachmentContentBlocks.test.ts create mode 100644 packages/happy-cli/src/claude/utils/attachmentContentBlocks.ts diff --git a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts index cb7801a25b..10bab0d8e7 100644 --- a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts +++ b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts @@ -16,7 +16,8 @@ import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { getToolName } from "./utils/getToolName"; import { getAskUserQuestionToolCallIds } from "./utils/questionNotification"; import { cleanupStdinAfterInk } from "@/utils/terminalStdinCleanup"; -import type { MessageParam, ContentBlockParam } from '@anthropic-ai/sdk/resources'; +import type { MessageParam } from '@anthropic-ai/sdk/resources'; +import { attachmentsToContentBlocks } from '@/claude/utils/attachmentContentBlocks'; interface PermissionsField { date: number; @@ -346,31 +347,8 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // to wait out here — just consume what travelled with the batch. const attachments = msg.attachments ?? []; if (attachments.length > 0) { - const contentBlocks: ContentBlockParam[] = []; - for (const att of attachments) { - // Detect media type from the decrypted bytes' magic header - // rather than trusting the wire-supplied mimeType. iOS image - // pickers happily report things like "image/heic" or no - // mimeType at all, which the Anthropic API rejects with a - // strict enum validation error. If the bytes look like one - // of the four formats Claude accepts, send that label — - // otherwise skip the attachment with a debug log. - const detected = detectClaudeImageMime(att.data); - if (!detected) { - logger.debug(`[remote] Skipping unsupported attachment (no magic-byte match): ${att.name}, claimed mimeType=${att.mimeType}`); - continue; - } - contentBlocks.push({ - type: 'image' as const, - source: { - type: 'base64' as const, - media_type: detected, - data: Buffer.from(att.data).toString('base64'), - }, - }); - } - contentBlocks.push({ type: 'text' as const, text: msg.message }); - logger.debug(`[remote] Combined ${contentBlocks.length - 1} image(s) with text message`); + const contentBlocks = attachmentsToContentBlocks(attachments, msg.message); + logger.debug(`[remote] Combined ${contentBlocks.length - 1} attachment block(s) with text message`); return { message: contentBlocks, mode: msg.mode, @@ -518,31 +496,3 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | return exitReason || 'exit'; } -/** - * Detect the image media type Claude accepts from the decrypted blob's - * magic-byte header. The wire-supplied mimeType is unreliable (iOS picker - * reports things like "image/heic" or no value at all), and the Anthropic - * API enforces a strict enum on `image.source.base64.media_type`. Returning - * null when the bytes don't match a supported format causes the caller to - * drop the attachment instead of shipping an invalid request that the API - * rejects with HTTP 400. - */ -function detectClaudeImageMime(bytes: Uint8Array): 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | null { - if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { - return 'image/png'; - } - if (bytes.length >= 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) { - return 'image/jpeg'; - } - if (bytes.length >= 4 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { - return 'image/gif'; - } - if ( - bytes.length >= 12 && - bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && - bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 - ) { - return 'image/webp'; - } - return null; -} diff --git a/packages/happy-cli/src/claude/utils/attachmentContentBlocks.test.ts b/packages/happy-cli/src/claude/utils/attachmentContentBlocks.test.ts new file mode 100644 index 0000000000..365421af63 --- /dev/null +++ b/packages/happy-cli/src/claude/utils/attachmentContentBlocks.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { attachmentsToContentBlocks } from './attachmentContentBlocks'; + +const PNG = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3]); +const PDF = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]); // "%PDF-1.4" +const TEXT = new TextEncoder().encode('hello\nworld'); +const BINARY = new Uint8Array([0x00, 0xFF, 0x13, 0x37, 0x00, 0x01]); + +describe('attachmentsToContentBlocks', () => { + it('converts a PNG to an image block', () => { + const blocks = attachmentsToContentBlocks( + [{ data: PNG, mimeType: 'image/png', name: 'shot.png' }], 'look'); + expect(blocks[0]).toMatchObject({ type: 'image', source: { type: 'base64', media_type: 'image/png' } }); + expect(blocks[blocks.length - 1]).toEqual({ type: 'text', text: 'look' }); + }); + + it('converts a PDF to a document block regardless of declared mime', () => { + const blocks = attachmentsToContentBlocks( + [{ data: PDF, mimeType: 'application/octet-stream', name: 'doc.pdf' }], 'read'); + expect(blocks[0]).toMatchObject({ type: 'document', source: { type: 'base64', media_type: 'application/pdf' } }); + }); + + it('inlines text/* attachments as fenced text blocks with filename', () => { + const blocks = attachmentsToContentBlocks( + [{ data: TEXT, mimeType: 'text/plain', name: 'log.txt' }], 'check'); + expect(blocks[0].type).toBe('text'); + const text = (blocks[0] as { type: 'text'; text: string }).text; + expect(text).toContain('log.txt'); + expect(text).toContain('hello\nworld'); + }); + + it('inlines UTF-8 attachments with unknown mime by extension fallback', () => { + const blocks = attachmentsToContentBlocks( + [{ data: TEXT, mimeType: 'application/octet-stream', name: 'notes.md' }], 'check'); + expect(blocks[0].type).toBe('text'); + }); + + it('emits a visible notice for unsupported binary attachments', () => { + const blocks = attachmentsToContentBlocks( + [{ data: BINARY, mimeType: 'application/octet-stream', name: 'blob.bin' }], 'hi'); + const last = blocks[blocks.length - 1] as { type: 'text'; text: string }; + expect(last.type).toBe('text'); + expect(last.text).toContain('blob.bin'); + expect(last.text).toContain('not a supported'); + expect(last.text).toContain('hi'); + }); + + it('returns a single text block when there are no attachments', () => { + expect(attachmentsToContentBlocks([], 'just text')) + .toEqual([{ type: 'text', text: 'just text' }]); + }); + + it('skips HEIC bytes that fail magic detection (defense in depth)', () => { + const heicish = new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); // ftyp box, no JPEG/PNG magic + const blocks = attachmentsToContentBlocks( + [{ data: heicish, mimeType: 'image/heic', name: 'pic.heic' }], 'hi'); + const last = blocks[blocks.length - 1] as { type: 'text'; text: string }; + expect(last.text).toContain('pic.heic'); + }); +}); diff --git a/packages/happy-cli/src/claude/utils/attachmentContentBlocks.ts b/packages/happy-cli/src/claude/utils/attachmentContentBlocks.ts new file mode 100644 index 0000000000..7f0e6575f6 --- /dev/null +++ b/packages/happy-cli/src/claude/utils/attachmentContentBlocks.ts @@ -0,0 +1,107 @@ +/** + * Converts decrypted attachments into Claude API content blocks. + * + * Routing, in priority order on the decrypted BYTES (wire mimeType is + * advisory only — iOS pickers lie): + * 1. JPEG/PNG/GIF/WebP magic -> image block + * 2. %PDF- magic -> document block (application/pdf) + * 3. text/* mime, known text extension, or clean UTF-8 decode -> fenced text block + * 4. anything else -> visible notice appended to the text message + * (previously these were dropped with only a debug log) + */ +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'; + +export type PendingAttachmentLike = { data: Uint8Array; mimeType: string; name: string }; + +/** + * Detect the image media type Claude accepts from the decrypted blob's + * magic-byte header. The wire-supplied mimeType is unreliable (iOS picker + * reports things like "image/heic" or no value at all), and the Anthropic + * API enforces a strict enum on `image.source.base64.media_type`. Returning + * null when the bytes don't match a supported format causes the caller to + * drop the attachment instead of shipping an invalid request that the API + * rejects with HTTP 400. + */ +export function detectClaudeImageMime(bytes: Uint8Array): 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | null { + if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { + return 'image/png'; + } + if (bytes.length >= 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) { + return 'image/jpeg'; + } + if (bytes.length >= 4 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { + return 'image/gif'; + } + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 + ) { + return 'image/webp'; + } + return null; +} + +function isPdf(bytes: Uint8Array): boolean { + return bytes.length >= 5 && + bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46 && bytes[4] === 0x2D; +} + +const TEXT_EXTENSIONS = new Set([ + 'txt', 'md', 'log', 'json', 'yaml', 'yml', 'csv', 'xml', 'html', 'css', + 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'h', 'cpp', + 'sh', 'toml', 'ini', 'cfg', 'conf', 'sql', 'swift', 'kt', 'env', +]); + +function decodeAsText(att: PendingAttachmentLike): string | null { + const ext = att.name.includes('.') ? att.name.split('.').pop()!.toLowerCase() : ''; + const looksTextual = att.mimeType.startsWith('text/') || TEXT_EXTENSIONS.has(ext); + try { + const decoded = new TextDecoder('utf-8', { fatal: true }).decode(att.data); + // Reject decodes full of control chars even if technically valid UTF-8, + // unless mime/extension already vouches for it. + if (!looksTextual && /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(decoded)) return null; + return decoded; + } catch { + return null; + } +} + +export function attachmentsToContentBlocks( + attachments: PendingAttachmentLike[], + messageText: string, +): ContentBlockParam[] { + const blocks: ContentBlockParam[] = []; + const unsupported: string[] = []; + + for (const att of attachments) { + const imageMime = detectClaudeImageMime(att.data); + if (imageMime) { + blocks.push({ + type: 'image', + source: { type: 'base64', media_type: imageMime, data: Buffer.from(att.data).toString('base64') }, + }); + continue; + } + if (isPdf(att.data)) { + blocks.push({ + type: 'document', + source: { type: 'base64', media_type: 'application/pdf', data: Buffer.from(att.data).toString('base64') }, + }); + continue; + } + const text = decodeAsText(att); + if (text !== null) { + blocks.push({ type: 'text', text: `Attached file "${att.name}":\n\`\`\`\n${text}\n\`\`\`` }); + continue; + } + unsupported.push(att.name); + } + + let tail = messageText; + if (unsupported.length > 0) { + tail += `\n\n[Note: attachment(s) ${unsupported.map(n => `"${n}"`).join(', ')} were not a supported type and were omitted.]`; + } + blocks.push({ type: 'text', text: tail }); + return blocks; +} From 36052b7903239a7af4c34a9ea2dc2c1461b51244 Mon Sep 17 00:00:00 2001 From: Jason Lixfeld Date: Fri, 12 Jun 2026 17:49:47 -0400 Subject: [PATCH 11/11] feat(app): paste clipboard image into chat on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Paste from Clipboard" row to the native attachment action sheet. It appears only when the clipboard holds an image (gated by Clipboard.hasImageAsync, which is silent — no iOS paste banner until the user taps it). pasteImage() reads the image via expo-clipboard, stages the base64 to a cacheDirectory file (the upload path needs a file:// URI), then runs it through the existing normalize/thumbhash/size-cap pipeline so pasted images behave identically to picked ones. Native-only; web already has its own paste listener. Adds optionPaste + pasteFailed strings to _default and all 10 locales. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../sources/-session/SessionView.tsx | 15 ++++-- .../happy-app/sources/hooks/useImagePicker.ts | 53 ++++++++++++++++++- packages/happy-app/sources/text/_default.ts | 3 ++ .../happy-app/sources/text/translations/ca.ts | 3 ++ .../happy-app/sources/text/translations/en.ts | 3 ++ .../happy-app/sources/text/translations/es.ts | 3 ++ .../happy-app/sources/text/translations/it.ts | 3 ++ .../happy-app/sources/text/translations/ja.ts | 3 ++ .../happy-app/sources/text/translations/pl.ts | 3 ++ .../happy-app/sources/text/translations/pt.ts | 3 ++ .../happy-app/sources/text/translations/ru.ts | 3 ++ .../sources/text/translations/zh-Hans.ts | 3 ++ .../sources/text/translations/zh-Hant.ts | 3 ++ 13 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/happy-app/sources/-session/SessionView.tsx b/packages/happy-app/sources/-session/SessionView.tsx index b16df8cd4a..4f428cc9df 100644 --- a/packages/happy-app/sources/-session/SessionView.tsx +++ b/packages/happy-app/sources/-session/SessionView.tsx @@ -477,19 +477,26 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: // Image attachment state (expImageUpload feature flag) const expImageUpload = useSetting('expImageUpload'); - const { selectedImages, pickImages, takePhoto, pickFiles, removeImage, clearImages, addImages } = useImagePicker(); - const handlePickAttachment = React.useCallback(() => { + const { selectedImages, pickImages, takePhoto, pickFiles, pasteImage, removeImage, clearImages, addImages } = useImagePicker(); + const handlePickAttachment = React.useCallback(async () => { if (Platform.OS === 'web') { pickImages(); return; } + // Only surface the paste row when the clipboard actually holds an image. + // hasImageAsync is silent (no iOS paste banner); the banner only fires + // later if the user taps Paste, which calls getImageAsync. + const hasClipboardImage = await Clipboard.hasImageAsync().catch(() => false); Modal.alert(t('imageUpload.addTitle'), undefined, [ { text: t('imageUpload.optionLibrary'), onPress: () => { pickImages(); } }, { text: t('imageUpload.optionCamera'), onPress: () => { takePhoto(); } }, { text: t('imageUpload.optionFiles'), onPress: () => { pickFiles(); } }, - { text: t('common.cancel'), style: 'cancel' }, + ...(hasClipboardImage + ? [{ text: t('imageUpload.optionPaste'), onPress: () => { pasteImage(); } }] + : []), + { text: t('common.cancel'), style: 'cancel' as const }, ]); - }, [pickImages, takePhoto, pickFiles]); + }, [pickImages, takePhoto, pickFiles, pasteImage]); // ChatComposer owns the message state + useDraft subscription. We only // hold an imperative handle so handleSend can read the live text and diff --git a/packages/happy-app/sources/hooks/useImagePicker.ts b/packages/happy-app/sources/hooks/useImagePicker.ts index 9af497ee28..24eab9bf4e 100644 --- a/packages/happy-app/sources/hooks/useImagePicker.ts +++ b/packages/happy-app/sources/hooks/useImagePicker.ts @@ -17,6 +17,9 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import * as ImagePicker from 'expo-image-picker'; import * as DocumentPicker from 'expo-document-picker'; +import * as Clipboard from 'expo-clipboard'; +import { writeAsStringAsync, cacheDirectory, EncodingType } from 'expo-file-system/legacy'; +import { randomUUID } from 'expo-crypto'; import { Platform } from 'react-native'; import { Modal } from '@/modal'; import { generateThumbhash } from '@/utils/thumbhash'; @@ -35,6 +38,7 @@ type UseImagePickerResult = { pickImages: () => Promise; takePhoto: () => Promise; pickFiles: () => Promise; + pasteImage: () => Promise; removeImage: (id: string) => void; clearImages: () => void; addImages: (images: AttachmentPreview[]) => void; @@ -220,6 +224,53 @@ export function useImagePicker(): UseImagePickerResult { } }, []); + // Paste an image from the system clipboard (iOS/Android). Clipboard returns + // a base64 data URI; the upload path needs a file:// URI (readFileBytes uses + // expo-file-system), so we stage the bytes to cacheDirectory first, then run + // it through the same normalize/thumbhash pipeline as picked images. + const pasteImage = useCallback(async () => { + const remaining = MAX_IMAGES_PER_MESSAGE - selectedCountRef.current; + if (remaining <= 0) { + Modal.alert( + t('imageUpload.limitTitle'), + t('imageUpload.limitMessage', { max: MAX_IMAGES_PER_MESSAGE }), + [{ text: t('common.ok') }], + ); + return; + } + + const image = await Clipboard.getImageAsync({ format: 'jpeg' }).catch(() => null); + // null here means the clipboard image is gone or iOS denied paste access + // (the two are indistinguishable on iOS 16+). + if (!image) { + Modal.alert( + t('imageUpload.pasteFailedTitle'), + t('imageUpload.pasteFailedMessage'), + [{ text: t('common.ok') }], + ); + return; + } + + if (!cacheDirectory) return; + const base64 = image.data.replace(/^data:image\/\w+;base64,/, ''); + const uri = `${cacheDirectory}happy-paste-${randomUUID()}.jpg`; + await writeAsStringAsync(uri, base64, { encoding: EncodingType.Base64 }); + + const asset = { + uri, + width: image.size.width, + height: image.size.height, + mimeType: 'image/jpeg', + fileName: `pasted_${Date.now()}.jpg`, + fileSize: undefined, + } as ImagePicker.ImagePickerAsset; + + const previews = await assetsToPreviews([asset]); + if (previews.length > 0) { + setSelectedImages(prev => [...prev, ...previews].slice(0, MAX_IMAGES_PER_MESSAGE)); + } + }, [assetsToPreviews]); + const removeImage = useCallback((id: string) => { setSelectedImages(prev => prev.filter(img => img.id !== id)); }, []); @@ -236,5 +287,5 @@ export function useImagePicker(): UseImagePickerResult { }); }, []); - return { selectedImages, pickImages, takePhoto, pickFiles, removeImage, clearImages, addImages }; + return { selectedImages, pickImages, takePhoto, pickFiles, pasteImage, removeImage, clearImages, addImages }; } diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index d3e4e8c4cb..1acd0d502c 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -239,6 +239,9 @@ export const en = { optionLibrary: 'Photo Library', optionCamera: 'Take Photo', optionFiles: 'Choose File', + optionPaste: 'Paste from Clipboard', + pasteFailedTitle: 'Paste Failed', + pasteFailedMessage: 'Could not read an image from the clipboard.', }, errors: { diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index c92ad7593e..dce7165e7d 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -987,6 +987,9 @@ export const ca: TranslationStructure = { optionLibrary: 'Fototeca', optionCamera: 'Fes una foto', optionFiles: 'Tria un fitxer', + optionPaste: 'Enganxa des del porta-retalls', + pasteFailedTitle: 'Ha fallat enganxar', + pasteFailedMessage: 'No s\'ha pogut llegir cap imatge del porta-retalls.', }, feed: { diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index 531333ca61..ecedcd8579 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -1002,6 +1002,9 @@ export const en: TranslationStructure = { optionLibrary: 'Photo Library', optionCamera: 'Take Photo', optionFiles: 'Choose File', + optionPaste: 'Paste from Clipboard', + pasteFailedTitle: 'Paste Failed', + pasteFailedMessage: 'Could not read an image from the clipboard.', }, feed: { diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 33813382b1..793983f9eb 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -988,6 +988,9 @@ export const es: TranslationStructure = { optionLibrary: 'Fototeca', optionCamera: 'Tomar foto', optionFiles: 'Elegir archivo', + optionPaste: 'Pegar desde el portapapeles', + pasteFailedTitle: 'Error al pegar', + pasteFailedMessage: 'No se pudo leer una imagen del portapapeles.', }, feed: { diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 642a000c94..448b84fee8 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -986,6 +986,9 @@ export const it: TranslationStructure = { optionLibrary: 'Libreria foto', optionCamera: 'Scatta foto', optionFiles: 'Scegli file', + optionPaste: 'Incolla dagli appunti', + pasteFailedTitle: 'Incolla non riuscito', + pasteFailedMessage: 'Impossibile leggere un\'immagine dagli appunti.', }, feed: { diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 9ac8606e46..6f19ae15ef 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -989,6 +989,9 @@ export const ja: TranslationStructure = { optionLibrary: 'フォトライブラリ', optionCamera: '写真を撮る', optionFiles: 'ファイルを選択', + optionPaste: 'クリップボードから貼り付け', + pasteFailedTitle: '貼り付けに失敗しました', + pasteFailedMessage: 'クリップボードから画像を読み取れませんでした。', }, feed: { diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index bcbe71a6f5..e28d596eb6 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -1017,6 +1017,9 @@ export const pl: TranslationStructure = { optionLibrary: 'Biblioteka zdjęć', optionCamera: 'Zrób zdjęcie', optionFiles: 'Wybierz plik', + optionPaste: 'Wklej ze schowka', + pasteFailedTitle: 'Wklejanie nie powiodło się', + pasteFailedMessage: 'Nie udało się odczytać obrazu ze schowka.', }, feed: { diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index 96f2911755..f67600ff65 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -986,6 +986,9 @@ export const pt: TranslationStructure = { optionLibrary: 'Biblioteca de fotos', optionCamera: 'Tirar foto', optionFiles: 'Escolher arquivo', + optionPaste: 'Colar da área de transferência', + pasteFailedTitle: 'Falha ao colar', + pasteFailedMessage: 'Não foi possível ler uma imagem da área de transferência.', }, feed: { diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index c61b6f34ec..19d2bdd3d5 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -1016,6 +1016,9 @@ export const ru: TranslationStructure = { optionLibrary: 'Фототека', optionCamera: 'Сделать фото', optionFiles: 'Выбрать файл', + optionPaste: 'Вставить из буфера обмена', + pasteFailedTitle: 'Не удалось вставить', + pasteFailedMessage: 'Не удалось прочитать изображение из буфера обмена.', }, feed: { diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index 1ac35f1147..cc7aff0d89 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -988,6 +988,9 @@ export const zhHans: TranslationStructure = { optionLibrary: '照片图库', optionCamera: '拍照', optionFiles: '选择文件', + optionPaste: '从剪贴板粘贴', + pasteFailedTitle: '粘贴失败', + pasteFailedMessage: '无法从剪贴板读取图片。', }, feed: { diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index d162853498..125a5f0e41 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -987,6 +987,9 @@ export const zhHant: TranslationStructure = { optionLibrary: '照片圖庫', optionCamera: '拍照', optionFiles: '選擇檔案', + optionPaste: '從剪貼簿貼上', + pasteFailedTitle: '貼上失敗', + pasteFailedMessage: '無法從剪貼簿讀取圖片。', }, feed: {