mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
* docs(mobile): establish independence rules and tech-stack baseline - Refactor root CLAUDE.md sharing rules into a single Sharing Principles section, replacing scattered mentions across 10 places with one source of truth + minimal "(web + desktop)" qualifiers on existing sections - Add apps/mobile/CLAUDE.md with locked tech-stack baseline: Expo SDK 54, React Native 0.81, NativeWind 4 + Tailwind 3.4, react-native-reusables, TanStack Query 5, Zustand, expo-secure-store - Mobile pins React directly (does NOT track root catalog:) so the Expo SDK / RN release schedule isn't blocked by web/desktop upgrades - Visual tokens are mobile-owned (transcribed from packages/ui/styles/ tokens.css by hand, not imported); Tailwind v3.4 vs v4 mismatch makes file sharing impractical anyway - Document mobile build/release pipeline (main CI excludes mobile, separate mobile-verify and mobile-release workflows, EAS Update for OTA) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): v1 shell — auth, workspace switching, inbox + my-issues - Auth: email OTP login mirroring packages/core/auth/store.ts behavior (401 clears token, non-401 preserves; token written only on verify success); expo-secure-store with key "multica_token" matching desktop - Workspace context: /[workspace]/ URL slug as source of truth (deep- link friendly), ApiClient auto-injects X-Workspace-Slug, SecureStore persists last-selected slug for cold-start restore - Bottom tabs (Ionicons): Inbox / My Issues / Settings - Inbox: actor avatar, unread brand-dot, status icon, time-ago + body subtitle. getInboxDisplayTitle mirrored from packages/views/inbox/ components/inbox-display.ts - My Issues: priority bars (matching IssuePriority bar counts from packages/core/issues/config/priority.ts), status dot, identifier, title, assignee avatar - Settings: account info + workspace switcher; switching replaces nav to /[newSlug]/inbox so back stack doesn't trail to old workspace - Multi-env: .env.staging / .env.production / .env.development.local with EXPO_PUBLIC_API_URL; APP_ENV in app.config.ts swaps bundleIdentifier so dev/staging/prod coexist on a device - Build: dev:mobile + dev:mobile:staging scripts; main turbo build/typecheck/lint/test filter excludes @multica/mobile Tech-stack (locked in apps/mobile/CLAUDE.md): - Expo SDK 55, RN 0.83.6, React 19.2.0 (pinned, NOT catalog) - NativeWind 4 + Tailwind 3.4 (intentional mismatch w/ web's Tailwind 4; visual tokens transcribed by hand from packages/ui/styles/tokens.css) - TanStack Query 5 with AppState focus listener; Zustand 5 Not in this commit (intentional): issue detail page, mark-read mutation, pull-to-refresh polish — next iteration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): unignore data/ + dedup, layout, mark-read, SVG icons, issue page Critical: previous commit (def9c08d) was missing apps/mobile/data/ entirely because root .gitignore has a generic `data/` rule (for backend runtime dirs) that swallowed mobile's source tree. Added !data/ override to apps/mobile/.gitignore. The branch was running locally only because untracked files still load at runtime. Functional changes on top: - Status icon: react-native-svg, 7 variants (backlog 16-dot ring / todo / in_progress 0.5 / in_review 0.75 / done + check / blocked + slash / cancelled + x). Geometry mirrors packages/views/issues/components/ status-icon.tsx (14x14 viewBox, OUTER_R=6, FILL_R=3.5) - Priority icon: 4 ascending bars + "none" horizontal dash; mirrors web priority-icon.tsx. Urgent pulse animation deferred. - Inbox row click: optimistic mark-read (mirrors packages/core/inbox/ mutations.ts useMarkInboxRead) + router.push to /[ws]/issue/[id] - My Issues row click: router.push to /[ws]/issue/[id] - /[ws]/issue/[id] placeholder with native iOS Stack header + back button + edge-swipe-to-dismiss - Inbox layout: title-row right edge = StatusIcon, body-row right edge = timeAgo, vertically aligned (matches web inbox-list-item.tsx) - InboxDetailLabel mobile mirror at components/inbox/detail-label.tsx — type-aware second-line ("Set status to (icon) Done" / "Mentioned" / "Assigned to <name>" etc.). Was rendering raw markdown body which leaked ## heading prefixes. - Inbox dedup: deduplicateInboxItems mirrored into apps/mobile/lib/ inbox-display.ts (filter archived -> group by issue_id -> keep newest -> sort desc). Without it mobile rendered 3 unread dots while web sidebar showed "Inbox 1". Documented in apps/mobile/CLAUDE.md "Behavioral parity" with the lesson: before rendering ANY list-shaped API response, mirror every preprocessing step web/desktop runs between useQuery and JSX (dedupe / coalesce / filter / display helpers). Backend returns raw cache shape; client shapes it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): ApiClient capability set + issue detail v1 + lessons in CLAUDE.md ApiClient hardening (data/api.ts): - onUnauthorized callback wired in _layout.tsx — 401 clears token, workspace store, TanStack Query cache, replaces nav to /login. Idempotent via signingOutRef. Mirrors packages/core/api/client.ts handleUnauthorized. - X-Request-ID per request (lib/request-id.ts) - Structured logger: `[api] -> METHOD path (rid)` on start, `[api] <- STATUS path (rid, duration)` on end. console.error for 5xx, console.warn for 404, console.log for success. - Zod parseWithFallback for listIssues + listTimeline (the only two endpoints with schemas in packages/core/api/schemas.ts today — matches web's current coverage; new schemas should land on the web side first and both clients pick them up). Core export (packages/core/package.json): - Add `./api/schemas` to exports map so mobile can import the shared Zod schemas + EMPTY_* fallbacks (pure data, on the mobile sharing whitelist per CLAUDE.md). Issue detail v1 (app/(app)/[workspace]/issue/[id].tsx): - Read issue + infinite-scroll timeline + comment composer - Stack header shows MUL-XXX once detail loads - Supporting files: data/queries/issues.ts, data/mutations/issues.ts, components/issue/{timeline-list,comment-composer,...}, lib/{format-activity,timeline-coalesce,timeline-thread}.ts - Property edits, reactions, mentions, image lightbox deferred to V2+ apps/mobile/CLAUDE.md — Lessons learned (encode into reflexes): 1. Install/upgrade deps: `pnpm view <pkg> dist-tags` first; `expo install` for Expo packages, never `pnpm add` blindly 2. New source subdirectory: `git check-ignore -v` to verify against root .gitignore generic rules (data/, build/, bin/); add !data/ override if matched. Cost a 14-file missing commit before. 3. ApiClient capability list (Zod parse / 401 callback / X-Request-ID / structured logger) — all baseline, not polish 4. Visual alignment is baseline, not polish — tab icons, screen titles, right-column vertical alignment of trailing elements, type-aware secondary lines (mirror InboxDetailLabel, not raw item.body) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): activity row parity with web — lead icon, coalesce badge, single-line Activity rows previously showed a two-line `[verb] / [absolute time]` block with no icons, mismatching web (issue-detail.tsx:1046-1100). This redesign brings mobile in line: - Single-line layout: [lead icon] [name] [verb...truncate] [×N] [time→] - Contextual lead icon: StatusIcon(details.to) for status_changed, PriorityIcon(details.to) for priority_changed, inline Calendar SVG for due_date_changed, ActorAvatar(size=16) otherwise - Relative time right-aligned (drops the made-up "Linear-style" absolute timestamp; web uses relative + hover tooltip, mobile keeps relative only for v1) - Coalesce ×N badge for non-task actions; task_completed/failed already bake the count into their copy - Whole row text-xs muted-foreground — activity is supposed to feel quiet next to comment bubbles - FlatList contentContainer gap-3 owns row spacing; rows themselves drop their own py so spacing doesn't double up Calendar icon is an inline 16-line react-native-svg primitive — avoids adding lucide-react-native to the mobile baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): standalone markdown renderer with mentions, files, images, lightbox Replaces `<Text>{content}</Text>` placeholders in issue description and comment body with a full markdown pipeline at apps/mobile/lib/markdown/. Pipeline: preprocess → marked.lexer → AST transforms → RN component tree. Uses `marked` (~30KB JS parser) for CommonMark+GFM tokens; renderer is hand-written (~600 LoC) for full control over RN's text-in-text rules, mention chips, file cards, and inline-image-to-block promotion. Supported in this drop: - Headings, paragraphs, lists (ordered/unordered/task), block quotes, hr, fenced code (no syntax highlight), strong/em/del/codespan, autolinks - Mention chips: mention://member/<id>, mention://agent/<id>, mention://issue/<id> — name resolution via existing useActorLookup; issue tap navigates to /:slug/issue/:id - File cards: !file[name](url) preprocessed to [📎 name](url) link; Linking.openURL hands off to system viewers (PDF, doc, share sheet) - Inline images promoted to block siblings (AST pass) — marked always wraps `![]()` in paragraph and RN can't put Image inside Text - Real aspect ratio via Image.getSize, expo-image for caching/transition, global LightboxProvider with react-native-image-viewing for tap-to-zoom - Tables degrade to card-per-row with header:value pairs (mobile-friendly responsive pattern; horizontal scroll tables get lost on touch) - Embedded HTML stripped before lexing: <br> → newline, comments removed, other tags peeled to inner text. Residual html tokens render muted Cross-package: lifted preprocessMentionShortcodes to @multica/core/markdown so mobile can import it (mobile may import pure functions from core; cannot import from packages/ui per Sharing Principles). packages/ui/markdown keeps its own synced copy with a cross-reference comment — packages/ui cannot import from core (Package Boundary Rules), so two synced copies is the cleanest path. Drops the comment-card "📎 N attachments" placeholder; markdown rendering covers inline images and !file[] cards. attachments[] is backend cleanup metadata, not display content (matches web). New deps: marked@18, expo-image@55, react-native-image-viewing@0.2. All Expo Go compatible — no native modules added. Plan: ~/.claude/plans/plan-dynamic-narwhal.md Research: apps/mobile/docs/markdown-renderer-research.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip(mobile): markdown engine swap to enriched-markdown + sprint progress Bundles the markdown rendering overhaul plus in-flight mobile feature work as a single WIP for review. Markdown work (the new direction): - Swap internal Markdown component from hand-rolled marked walker to react-native-enriched-markdown (Software Mansion, native md4c). Public API <Markdown content={...} /> unchanged; consumers untouched. Mention links degrade to colored links + onLinkPress routing. - Pre-swap fixes that landed first: 3-layer inline code (later corrected), Shiki via react-native-shiki-engine wired (now bypassed; code retained for selective re-enable on code blocks), code block copy button with expo-clipboard + expo-haptics, inline SVG copy/check icons, header scale calibrated to Apple HIG, paragraph leading-6 for CJK, list bullet column 24->16, lineBreakStrategyIOS="hangul-word" on outer paragraph Text. - Preprocess: <br> -> " \n" (CommonMark HardBreak) so md4c respects intentional breaks without misreading bare \n. - Drop the Expo Go compatibility constraint from CLAUDE.md and markdown-renderer-research.md (project runs on dev client). - New apps/mobile/docs/markdown-renderer-research.md captures the RN nested-Text rendering constraints (#10775 / #45925 / #6728), the CJK amplification mechanism, the typography scale calibration, and every decision-log entry from the engine evolution. Other in-flight mobile features included: - Issue detail timeline polish, comment composer + action sheet, mention suggestion bar, emoji picker sheet, reaction bar. - Status / priority / assignee / label / due date picker sheets. - My Issues filter sheet + view store. - Realtime layer (ws-client, realtime-provider, use-inbox-realtime). - Data layer additions (queries, mutations, schemas, attribute chips). Cross-package: - packages/core/api/schemas.ts: export IssueSchema for mobile use. Build: native rebuild required after pulling (enriched-markdown is a native Fabric module). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): 4-tab shell — Chat tab, More tab, single-row header, filter chips, modal stubs Scaffolds the next phase of mobile so per-feature work has a clean shell to fill into. No new business logic, no data fetching beyond what already existed; this is layout + navigation only. Tab restructure (3 → 4 tabs): - Add Chat tab placeholder (will port web bottom-right chat widget logic). - Rename Settings → More; convert to grouped iOS-style list with sections Workspace / Personal / Account / Workspaces, all SectionGroup + NavRow. - Workspace switcher list inside More uses the same NavRow visual pattern (active row marks with checkmark, inactive shows chevron). Header (single-row): - ScreenHeader simplified to one row: large title left, right actions slot. Removed the second-row WS switcher idea — switcher only lives in More now (the global header would mix scope levels with global actions). - New HeaderActions component holds the two global actions: search and create-issue. Wired into all 4 tabs. My Issues filter relocation: - Filter button moved out of the header right slot (was a scope-mismatch hazard — global header should not host tab-local controls). Now sits inline at the right end of the ScopeTabs row. - New ActiveFilterChips row renders below ScopeTabs when filters are active; each chip is tap-to-clear. Mirrors iOS Mail/Things UX. Stubs for next phase: - [workspace]/new-issue.tsx and [workspace]/search.tsx as modal screens presented from HeaderActions. Both have a Cancel button (new ModalCloseButton) in headerLeft. - More tab sub-pages: more/{projects,agents,pins,notifications}.tsx registered in [workspace]/_layout.tsx with native Stack headers. Cross-cutting: - lib/issue-status.ts exports PRIORITY_LABEL alongside STATUS_LABEL (used by the new filter chip row). - All new code uses Ionicons from @expo/vector-icons; not adding lucide-react-native — see comment-composer.tsx for the reasoning. Verified: pnpm --filter @multica/mobile typecheck passes; lint shows only pre-existing issues unrelated to this change; more/ subdirectory checked against .gitignore per CLAUDE.md mobile rule 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): hybrid markdown — Shiki code + lightbox images, prose via enriched react-native-enriched-markdown does not expose JS-level custom renderers (issues #54, #232, #246), so syntax highlighting, tap-to-lightbox, and copy buttons cannot live inside enriched. Maintainer-endorsed workaround (#246): split markdown at those boundaries and render the leaves in React. splitMarkdown walks marked.lexer tokens and emits prose / code / image segments. Each prose island gets its own EnrichedMarkdownText; code blocks reuse the in-house CodeBlock (Shiki + copy + horizontal scroll); images reuse MarkdownImage (expo-image + lightbox). Paragraph-embedded images are promoted to block siblings, matching GitHub mobile and Linear iOS. Drops ~600 LOC of dead walker code (render-block, render-inline, ast, link, mention-chip, key) that the previous engine swap left behind. Visual polish for the hybrid output: - inline code alpha 20% → 12%; enriched paints over the full line height and RN can't apply the padding/radius/0.85em that keep GitHub web's chip compact, so the web alpha reads too heavy here. - new `code-surface` token (#e8e8eb), one step darker than `secondary`, plus a 1px `border-border` hairline. Code block now elevates inside both white issue bodies and grey comment cards. - code block margin my-3 — breathing room both sides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): new issue creation — Manual mode fully wired with @ mention Mobile can now actually create issues. Phase 1 left submit as a console.log stub; this iteration wires Manual mode end-to-end so an issue typed on a phone lands in the backend and appears in the user's my-issues list on next refresh. Wire-up: - api.createIssue(body) — POST /api/issues, mirroring server route at server/cmd/server/router.go:320. Matches the CreateIssueRequest type exported from @multica/core/types so payload shape agrees across clients. - useCreateIssue() mutation in data/mutations/issues.ts — no optimistic insert (the my-issues list is status-bucketed + scope-filtered, so optimism needs bucket+scope decisions; invalidation is simpler and hosted-backend latency is sub-300ms). onSuccess invalidates myAll and inbox query keys. - new-issue.tsx Manual panel: submit ↑ calls mutateAsync, dismisses on success, surfaces errors via Alert.alert with the form state preserved so the user can retry. Button shows a spinner during the in-flight request and all inputs are disabled. @ mention in description (members + agents): - Mirrors comment-composer.tsx pattern exactly — selection tracking, tokenAtCursor on every change/selection event, MentionSuggestionBar rendered above the chip row, insertMention on pick, markers list appended. - Title input stays plain (web doesn't allow mentions in title; we mirror that). - Wire format on submit: serializeMentions(description, markers) → `[@name](mention://type/id)` markdown. Recognised by: * server/internal/util/mention.go ParseMentions * packages/views/editor/extensions/mention-extension.ts (web Tiptap) * apps/mobile/components/issue/mention-chip.tsx (mobile timeline) - Backend does NOT trigger inbox notifications for mentions in issue descriptions (only on comments — see server/internal/handler/comment.go ParseMentions call). Mobile doesn't need to send a separate mentioned_* field; the markdown alone is sufficient. Header polish: - SubmitIssueButton accepts a `loading` prop; renders ActivityIndicator in place of the ↑ glyph while pending. Defends against double-tap. - ModalCloseButton's earlier "Cancel" text is now a ✕ icon in a circle to match the new-issue / search modal visual reference (Linear-style). Agent mode unchanged — still a placeholder that console.logs and dismisses. Phase 3 will wire the real agent picker, apiClient .quickCreateIssue, and the daemon version gate. Explicitly NOT in this commit (later phases): - Markdown formatting toolbar (Phase 2C) - Project / Labels / Due date / Parent chips (Phase 2D) - Image / file attachments (Phase 2E) - #MUL-42 issue references, @all mention - Draft persistence, "Create Another" toggle - Pre-fill from sub-issue entry, optimistic list insert - Success toast (success path = silent dismiss; mobile has no toast component yet) Verified: pnpm --filter @multica/mobile typecheck passes; lint shows only pre-existing issues unrelated to this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): WS realtime coverage — issue detail / my issues / agent tasks Previous iteration shipped issue creation but mobile only ran WS for inbox. Anything else (issue detail, my-issues list, agent task progress) was pull-refresh only. Cross-client edits, agents working in the background, and concurrent user changes all required the user to manually refresh. This commit closes that gap so all four user-facing surfaces stay live without input. Mobile now matches web/desktop in product freshness, while keeping mobile-specific patterns (patch over invalidate, per-screen mount, event-always-wins) that reflect cellular and AppState constraints. New (3 files): - data/realtime/issue-ws-updaters.ts — mobile-owned cache patchers. Pure functions over QueryClient: patchIssueDetail, prependTimelineEntry, patchTimelineEntry, removeTimelineEntry, patchMyIssuesList, removeFromMyIssuesList, addCommentReaction, removeCommentReaction, addIssueReaction, removeIssueReaction, patchIssueLabels, commentToTimelineEntry. NOT imported from packages/core because web's updaters bind to web's issueKeys instance and target bucketed caches mobile doesn't have — see CLAUDE.md "Mobile-owned updaters" rule. - data/realtime/use-issue-realtime.ts — per-issue subscriptions mounted by the detail screen. Subscribes to 11 issue/comment/activity/reaction events plus 6 task:* events for live agent progress. Every handler filters by issue_id so we ignore noise from other issues. Reconnect invalidates only this issue's detail + timeline (not a global sweep). On issue:deleted for the active id, runs onDeleted callback so the screen can router.back() rather than strand the user on a 404. - data/realtime/use-my-issues-realtime.ts — listing-level subscriptions mounted globally. issue:created → invalidate myAll (we don't know scope/filter membership for a fresh issue). issue:updated → patch via setQueriesData across every cached scope/filter combination. issue:deleted → strip from every cached list. Reconnect → invalidate myAll. Modified (2 files): - app/(app)/[workspace]/_layout.tsx — RealtimeSubscriptions adds useMyIssuesRealtime alongside useInboxRealtime. Both are workspace- session lifetime. - app/(app)/[workspace]/issue/[id].tsx — mounts useIssueRealtime(id) with router.back as the onDeleted callback. Docs (apps/mobile/CLAUDE.md): New top-level section "## Realtime / WebSocket strategy" before the Lessons section. Documents: - Three-layer stack (ws-client → realtime-provider → per-feature hooks) - Mount strategy: list-level global vs per-record per-screen, and why mobile doesn't use a single centralized useRealtimeSync like web - Patch over invalidate (cellular-data rule) - Mobile-owned updaters (don't import packages/core/issues/ws-updaters) - Event-always-wins conflict policy - Per-hook reconnect scoping (no global invalidate sweep) - Recipe for adding new event coverage Out of scope (deferred): - Workspace member events (Phase 3D) — wait until More tab adds a real members list - "N new comments" floating banner — patch-only for now - Push notifications (APNs) — requires server config + entitlement Verified: pnpm --filter @multica/mobile typecheck passes; lint shows only pre-existing issues unrelated to this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): markdown segment spacing uses Yoga gap, not per-child margin Two consecutive fenced code blocks (and code-image / image-image combos) rendered with effectively zero gap on iOS — NativeWind 4 compiles `my-3` to `marginVertical: 12`, but Yoga's sibling margin behaviour doesn't accumulate the way web CSS does. Result: a `my-3` sibling pair landed at ~12px on the screen instead of 24px, and the border-on-border made it look like the two blocks were glued. Move the spacing from per-child `marginVertical` to a `gap-3` on the markdown root `<View>`. Gap is layout-level (Yoga implements it directly), independent of margin behaviour, and uniformly applies between every segment pair — prose ↔ code, code ↔ code, image ↔ code, etc. CodeBlock and MarkdownImage drop their `my-3` / `mb-3` since the parent now owns the spacing. Prose ↔ code reads as ~24px (prose's enriched-markdown `paragraph.marginBottom` 12 + root gap 12), which is the comfortable "new block" feel; code ↔ code reads as exactly 12px, which is the "these are related" feel. Both improve on the previous 0–8px crunch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): unified input UX — mention hook, markdown toolbar, file upload new-issue Description and Comment composer used to each carry their own copy of mention state (mentioning / recomputeMentioning / onChangeText / onSelectionChange / onAtButton / onSelectMention / serialize), ~50 LOC of identical boilerplate per surface. The description had no toolbar at all; the comment had a lone left-side `@` button. Visually the two body inputs looked like different products — description was bare text, comment was rounded-2xl bg-secondary with a focus tint. Three changes consolidate the body-input experience: 1. Shared mention pipeline. `useMentionInput()` in lib/use-mention-input.ts owns text / selection / markers / mentioning, plus handlers (onChangeText, onSelectionChange, onAtButtonPress), suggestion-bar props, `insertAtCursor`, `insertAtLineStart`, serialize, snapshot, restore, reset. Comment-composer and new-issue both consume it, killing the duplication. 2. Shared keyboard-bar markdown toolbar. Linear-iOS range: `@`, bullet list, checklist, code block, quote, image, file. All buttons are literal-character inserts via hook helpers — no WYSIWYG. Toggles like bold/italic are deliberately out of scope because RN TextInput can't render styled ranges inside the input; a real WYSIWYG would mean swapping to react-native-enriched and crossing an HTML <-> markdown boundary, which is a separate decision. 3. File upload. `api.uploadFile(asset, { issueId?, commentId? })` mirrors web's `/api/upload-file` contract but takes the RN-shaped `{ uri, name, type }` payload and validates the response against a strict `AttachmentSchema` (no silent fallback — an empty `url` would put a broken link into the editor). `useFileAttach()` glues expo-image-picker / expo-document-picker into the toolbar's image and file buttons. Context follows web: comments pass issueId, not-yet-created issues pass nothing. MAX_FILE_SIZE is mirrored, not imported, per mobile CLAUDE.md. Cleanup: - `MOBILE_PLACEHOLDER_COLOR` + `MIN_BODY_INPUT_HEIGHT_PX` in components/ui/input-tokens.ts; six hardcoded `#a1a1aa` callers now reference the const. - Description now sits in a rounded-2xl bg-secondary/40 container with a focus-tint border, visually matching the comment composer. - app.config.ts gets `expo-image-picker` plugin with `photosPermission` set and `cameraPermission` / `microphonePermission` disabled — without this Info.plist string, calling the image picker hard-crashes on iOS 14+. A dev-client rebuild is required (new native modules); existing behaviour and read-only rendering are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): hard 30s fetch timeout + TanStack Query signal pass-through Triggered by a real user-visible bug: the Inbox tab's pull-to-refresh spinner sometimes stuck on indefinitely after returning the app to the foreground. List items rendered normally underneath, but `isRefetching` never flipped back to `false`. Root cause: api.ts fetch() had no timeout, no AbortController, and ignored caller-supplied signals. iOS suspends background apps and can silently kill in-flight network tasks (facebook/react-native#35384, #38711). When the app foregrounded, the suspended Promise neither resolved nor rejected. TanStack Query saw a fetch already in flight and would not start a replacement on invalidate — it just waited forever on the dead Promise. Fix is three layers (all three required — partial fix leaves a footgun): 1. api.ts fetch() — hard 30s timeout via manual AbortController + setTimeout. Hermes does not implement AbortSignal.timeout() / AbortSignal.any() (facebook/react-native#42042, livekit#4014), so composition is via addEventListener("abort", ...) forwarding. On timeout we throw an ApiError(message, status=0) so callers see a real error instead of a Promise-that-never-settles. 2. All read-side api methods now accept opts?: { signal?: AbortSignal } and forward to fetch(): listInbox, listWorkspaces, getMe, listMembers, listAgents, listIssues, getIssue, listTimeline, listLabels, listProjects. Mutations are unchanged — TanStack Query doesn't pass a signal to mutationFn. 3. All queryFn definitions in data/queries/* now destructure { signal } and forward it. The TanStack official cancellation guide states that the signal is aborted when a query becomes out-of-date or inactive, so this is the primary mechanism that unwedges stuck queries (the 30s timeout is the safety net for cases where nothing else fires). Already in place (untouched, but documented): - query-client.ts wires focusManager ← AppState and onlineManager ← NetInfo per TanStack's React Native official guide. focusManager alone wasn't enough — when a fetch hangs, "focused = true" can't unstick the query without signal cancellation or timeout. The three pieces work together. Docs (apps/mobile/CLAUDE.md): New Lesson #5 captures all of the above with: - The original symptom + root cause - The three-part rule (timeout / api opts / queryFn destructure) - Hermes-specific caveats with citations to the upstream issues - A grep verification command future readers can run to enforce part 3 Verified: - pnpm --filter @multica/mobile typecheck passes - pnpm --filter @multica/mobile lint shows only pre-existing issues unrelated to this change - grep -n "queryFn: () =>" apps/mobile/data/queries/*.ts returns zero matches (every queryFn destructures signal) Sources cited in CLAUDE.md: - TanStack Query Cancellation guide (tanstack.com/query/v5) - TanStack Query React Native official guide (tanstack.com/query/v5) - facebook/react-native#42042 (AbortSignal.timeout unavailable in Hermes) - facebook/react-native#35384 (iOS background fetch failure) - facebook/react-native#38711 (iOS background JS Timers don't fire) - livekit/livekit#4014 (AbortSignal.any unavailable in React Native) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): chat v1 — single-tab IA, optimistic send, two-tier WS Fill the Chat tab placeholder. UX is mobile-native (top bar with tap-title sheet, message list, bottom composer — no two-layer nav); logic is at parity with web (API/events/has_unread/optimistic sequence/permissions/ enums all mirrored). Includes: - data layer: 8 chat API methods + zod schemas with .catch() enum drift fallback; queries / mutations (optimistic delete + markRead); per- session drafts store - two-tier realtime: listing-level hook mounted in workspace _layout (chat:session_* + chat:done for has_unread), per-record hook mounted in the chat screen (chat:message/done + 5 task:* events, all filtered by chat_session_id, scoped reconnect invalidates); ws-updaters carry an invalidate fallback for pre-#2123 servers that omit chat:done payload - rule mirrors: canAssignAgent, failureReasonLabel, agent availability three-state hook (mirror-not-import per apps/mobile/CLAUDE.md) - UI: ChatHeader (tap title → SessionSheet) + ChatMessageList (FlatList, destructive bubble on failure_reason) + ChatComposer (mention + markdown toolbar minus file/image) + StatusPill (Thinking · Ns) + SessionSheet (with agent avatars + long-press delete) + AgentPickerSheet + NoAgentBanner v1 cuts (deferred to v2): file upload, rename, Chat tab unread badge, agent presence dot, task tool_use detail expansion, focus mode route anchor, starter prompts, history pagination, mobile test infra. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): add due_date / project to create-issue, drop agent toggle Wire the last two CreateIssueRequest fields that have a meaningful UX on mobile (due_date, project_id) to the new-issue form via two new chips sharing the existing CreateFormAttributeRow + picker-sheet pattern. Fixes a silent 400 on the existing detail-page due_date update: the picker was emitting YYYY-MM-DD but server/internal/handler/issue.go parses with time.Parse(time.RFC3339, ...) which rejects date-only. Now sends full ISO, matching web's due-date-picker.tsx. Removes the placeholder agent-mode toggle from new-issue — it was a dead UI surface (logged to console on submit, never wired). Mobile's create-issue is now manual-only, aligned with web's form semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): redesign chat composer as floating card Move chat input to a rounded card with inline @ and Send/Stop buttons (Linear / iMessage idiom), dropping the markdown toolbar that comment- composer needs but chat doesn't. Send stays visible-but-disabled when there's no draft so the button row no longer jitters as the user types. Adds SF Symbols, expo-haptics, and reanimated crossfade for send↔stop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): add issue MentionType + viewed-issues store Extend MentionType with "issue" and serialize issue mentions without the leading `@` in the link label, matching web's mention-extension.ts:67-74. New in-memory LRU tracks recently viewed issues per workspace so the chat composer can surface them next. Issue detail screen pushes its id into the store on mount. Suggestion bar UI lands in a follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): @ in chat picks an issue (Recent + My issues) In 1:1 user↔agent chat sessions, @member and @agent are noise (no notification channel; the session is already bound to one agent). Switch the mention bar to surface issues instead — Recent (most recent 5 from the in-memory viewed-issues store) followed by My issues (assigned-to-me, max 10, deduped). The serialized token matches web byte-for-byte ([MUL-XXX](mention://issue/<uuid>)) so the agent can read the reference directly even though chat.go SendChatMessage doesn't yet run ParseMentions — that's a follow-up. MentionSuggestionBar gains a mode="comment"|"chat" prop; comment mode is the default and preserves existing behaviour for the issue comment composer and new-issue body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): stable empty reference in viewed-issues selector selectViewedIssueIds was returning a fresh `[]` when the workspace had no entry yet, which made useSyncExternalStore see a different snapshot on every read and trigger "getSnapshot should be cached" + infinite re-render. Share a single frozen empty array for all no-entry paths, matching the Zustand footgun rule in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): iMessage-style keyboard dismiss in chat message list Drag the list to interactively pull the keyboard down with the finger, or tap empty space between bubbles to dismiss. `handled` keeps long- press action sheets and other in-bubble Pressables firing normally. Sending a message intentionally keeps the input focused so the user can immediately type the next one — RN's default and the chat-app standard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): tap message area dismisses keyboard in chat keyboardShouldPersistTaps="handled" on FlatList has a long-standing RN bug (facebook/react-native#31448) that prevents the tap-to-dismiss path from firing in many setups. Wrap ChatMessageList with a Pressable that calls Keyboard.dismiss() — the canonical workaround documented in the RN Keyboard guide and the Expo keyboard-handling guide. Interactive drag-dismiss on the FlatList itself (the previous commit) is an independent code path and continues to work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): drop double home-indicator padding under chat composer chat.tsx wrote SafeAreaView edges={["top","bottom"]} while the parent <Tabs> container already absorbs the home-indicator inset on behalf of all tab screens. The result was ~34pt of empty space below the composer. Sibling tabs (inbox / my-issues / more) all use edges={["top"]} — chat was the outlier. The gap only became visible after the floating-card composer landed; the previous sticky-bar layout disguised it as bg-coloured padding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): simplify create-issue layout, fix render loop Reshape the new-issue modal into one vertical scrolling form (title → description → property chips), matching the Apple Reminders / Linear iOS pattern. Previously the chips sat sticky- pinned above the keyboard, which made them invisible when the keyboard was up and stranded at the bottom of an empty screen when it was down — neither state served the user. Drop the markdown toolbar and upload buttons from the modal: mobile users almost never format markdown when creating an issue, and attachment upload is deferred for this release. Removing them also lets the form breathe vertically. Fix the "Maximum update depth exceeded" loop that surfaced once real data started flowing. Root cause was duplicate useQuery(projectListOptions) subscribers in CreateFormAttributeRow and ProjectPickerSheet on the same key, under React 19 strict mode. Form now holds the full Project object lifted from the picker, so only the picker queries the list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): More tab opens global nav popover Replaces the full-screen More tab with a bottom-bar trigger that opens a popover containing the workspace switcher and 9 nav destinations (Inbox, My Issues, Favorites, Projects, Initiatives, Views, Teams, Settings, Search). Uses expo-router Tabs.Screen listeners.tabPress + preventDefault — the more.tsx route is a stub that redirects to inbox if hit directly. Custom Modal popover (no @gorhom/bottom-sheet) since that lib still requires Reanimated v3 and mobile is on v4. Account info + workspace list + sign out moved into a dedicated Settings page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): add projects feature with realtime cache sync Mobile parity for the projects domain — browse, detail, create, edit, delete, plus GitHub resource attach. UX adapted to iOS (Stack push + modal sheets, picker sheets per property, ActionSheet for Edit/Delete, collapsible Open/Done buckets in related issues) while preserving web's semantics: 5 status enums (incl. cancelled), 5 priorities, lead supports both members and agents, counts come from server fields. Data layer follows mobile CLAUDE.md rules: parseWithFallback + signal on every read, optimistic patch + WS event-always-wins on mutations, mobile-owned ws-updaters (not imported from packages/core) that patch over invalidate to honour the cellular-data rule. Per-record realtime hook subscribes to issue:* events filtered by project_id so the related-issues list stays fresh without pull-to-refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): redesign More popover — user card + lean nav - Add user identity card at top of GlobalNavMenu, mirroring web sidebar dropdown (packages/views/layout/app-sidebar.tsx:496). Tap pushes into the existing settings page where account / workspaces / sign-out already live. - Trim NAV_ITEMS to Projects only. Inbox / My Issues / Chat are bottom tabs; Settings is reached via the user card. - Delete six orphaned stub routes (favorites, initiatives, views, teams, notifications, pins) — no remaining external references. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): extract shared IssueRow + props-driven filter sheet - Add components/issue/issue-row.tsx as the single source for list-style issue rendering. `<IssueRow issue showStatus? />` — showStatus opt-in for ungrouped lists (project related-issues), default off where the SectionList header already shows status (my-issues). - Replace the two inline IssueRow copies in (tabs)/my-issues.tsx and components/project/project-related-issues.tsx. - Rename MyIssuesFilterSheet → IssueFilterSheet and replace store-coupled state with props so the same sheet can serve any view-store. My Issues call site passes useMyIssuesViewStore selectors as props. - Rename filterMyIssues → filterIssues (function was already generic; the misnomer just reflected the original single call site). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): workspace Issues page in More popover New surface for the workspace-wide issue list. Mirrors web's IssuesPage (packages/views/issues/components/issues-page.tsx) at mobile fidelity: SectionList grouped by status, status + priority filter (reuses the shared IssueFilterSheet), pull-to-refresh, empty/error states, IssueRow identical to other surfaces. Differs from My Issues by dropping the Assigned/Created/Agents scope tabs (workspace-wide list has no per-user scope) and using an independent view-store so filters don't bleed between the two pages. Plumbing: - data/queries/issues.ts → issueListOptions(wsId) using existing issueKeys.list(wsId) prefix (already wired into invalidations from mutations and project realtime). - data/stores/issues-view-store.ts → status/priority filter state. - data/realtime/use-issues-realtime.ts → list-level WS subscription; patches list(wsId) on issue:created (prepend) / updated / deleted, invalidates on reconnect. Mounted in <RealtimeSubscriptions />. - data/realtime/issue-ws-updaters.ts → patchIssuesList / prependToIssuesList / removeFromIssuesList, plus extending patchIssueLabels to also patch list(wsId). - workspace _layout: register more/issues Stack.Screen, drop Stack.Screen entries for the routes deleted in5cc7f01(favorites/initiatives/ views/teams/notifications/pins). Filters beyond status/priority (assignee/project/label/creator) are a v1.1 follow-up; v1 ships at My Issues parity for code reuse. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mobile): add Issues entry to More popover Wires the new workspace Issues page (more/issues.tsx) into GlobalNavMenu, ordered above Projects (higher-frequency surface). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mobile): rename ios run scripts to ios:device, add .env.example, document commands `expo run:ios` always meant device install in this project, but the unqualified `ios` / `ios:mobile` script names invited confusion with the simulator default. Rename to `ios:device` / `ios:device:staging` so the intent is explicit, and pair with a checked-in `.env.example` so a fresh clone knows which keys mobile needs. CLAUDE.md picks up the new command list under the existing Commands section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): drop paginated timeline, fetch as single ASC list Server-side timeline pagination was retired (#2322) because p99 issues have ~30 entries — cursors were pure overhead and split reply threads across page boundaries. Mobile mirrors the new shape: - `api.listTimeline` returns `TimelineEntry[]` directly (was `TimelinePage` with `next_cursor` + `has_more_before`). - `issueTimelineOptions` is a flat `queryOptions` (was `infiniteQueryOptions`); query consumers drop the page-walking dance. - WS handlers `comment:created` / `activity:created` now `append` (oldest-first ASC list) instead of `prepend`. Mirror updater renamed. - Timeline list view collapses to a single `FlatList data={entries}`, no more `pages.flat()` + `fetchNextPage` plumbing. Mirrors web's post-#2322 `issueTimelineOptions` shape (per apps/mobile/CLAUDE.md "mirror, don't import"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): restore Chat list scrolling + align bubble UI with web The Chat tab message list was unscrollable. Two distinct root causes under the same surface symptom: 1. Wrapper hijacking the touch responder. chat.tsx mounted a Pressable around ChatMessageList to implement "tap empty area = dismiss keyboard". Any Touchable* (Pressable / TouchableWithoutFeedback / TouchableOpacity) claims the responder via the shared Touchable mixin and does NOT reliably hand it back to the child FlatList for pan gestures, killing scroll. Removed entirely — `keyboardShouldPersistTaps ="handled"` on the FlatList already provides the same behaviour per RN docs (a tap not handled by a child bubble dismisses the keyboard), and `keyboardDismissMode="interactive"` covers drag-to-dismiss. Mirrors web's bare `<div className="flex-1 overflow-y-auto">` mount. 2. `onContentSizeChange` re-sticking to bottom on every async layout. Markdown async rendering (Shiki highlight, image natural-size resolution, lightbox provider injection) fires content-size changes for seconds after first paint. The previous handler called `scrollToEnd` unconditionally, snapping the user back to the bottom the instant they tried to drag up. Replaced with a sticky-bottom state machine — `isAtBottomRef` / `userHasScrolledRef` / `firstMsgIdRef` — that only re-sticks while the user is anchored at the bottom; reading history is left alone. Same semantic as iMessage and web ChatWindow. Bonus alignment with web's bubble styling: - User bubble: bg-muted (was bg-primary dark), max-w-[80%] (was 88%), text-foreground. - Assistant: w-full (was self-start max-w-[88%]) so Markdown / code blocks / tables get the full content width. - Outer content padding: px-4 pt-3 pb-4 gap-3 (was px-3 py-3 gap-2), matching web's `max-w-4xl px-5 py-4 space-y-4` rhythm at mobile scale and giving the last bubble breathing room above the composer. - FlatList itself gets `className="flex-1"` so its height is the remaining viewport in the KeyboardAvoidingView column, matching web's `flex-1 overflow-y-auto` host. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): default Chat tab to most recent session on first entry Web's chat-window opens to an empty state when no activeSessionId is persisted, because the sidebar SessionDropdown makes one-click switching cheap. On a phone, picking a session is 4 taps (header → sheet open → row → close), so an always-empty default is friction — users complained they had to re-pick the session every cold start. Mobile-only deviation: on the first Chat tab entry for a given workspace, jump straight to the most recent session (`sessions[0]`, server-sorted by `updated_at desc`). A per-workspace `useRef` flag makes the hydration a one-shot — subsequent user intent (point + New, delete-active) sets activeSessionId to null and is respected forever after. When the user switches workspaces, the ref resets so the new workspace gets its own first-entry hydration. Behavioural parity is preserved: counts / visibility / permissions / enums match web exactly. UX is allowed to diverge on UI mechanics per apps/mobile/CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): inbox row flips to read state before navigation push Tapping an unread inbox row produced no visible "now read" feedback — the row disappeared into the issue detail push transition still wearing its unread bullet and bold-foreground style. Users came back via the back button to find it had become read (correct cache state, just no real-time feedback). Root cause: `useMarkInboxRead.onMutate` does `await qc.cancelQueries` before the optimistic `setQueryData`, so the optimistic write lands one microtask after the synchronous `router.push`. iOS native stack captures the source view screenshot at push time — the screenshot freezes the row in its unread state, and the transition animates that frozen frame regardless of any later cache write. Fix: in `onPressItem`, do the optimistic `setQueryData` synchronously right before calling `markRead.mutate(...)`. The mutation still runs end-to-end (so the server PATCH fires and `onSettled` invalidate reconciles), but the row already shows the read style on the frame that gets screenshotted for the push transition. The tab-bar inbox badge also drops one count at the same instant for the same reason. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): unread badges on Inbox and Chat tabs Surface the same unread signals web puts on the sidebar (inbox) and the ChatFab (chat). On a phone the user lives on the tab bar, so mounting badges directly on the Inbox and Chat tabs is the closest equivalent. Display semantics mirror web exactly (apps/mobile/CLAUDE.md "counts must agree"): - Inbox badge = `deduplicateInboxItems(items).filter(i => !i.read).length`, same as web's `useInboxUnreadCount` (packages/core/inbox/queries.ts:22). 99+ truncation matches the sidebar. - Chat badge = `sessions.filter(s => s.has_unread).length`, same as web's ChatFab (packages/views/chat/components/chat-fab.tsx:29). 9+ truncation matches the fab. Implementation: - New `apps/mobile/lib/unread-counts.ts` with two `useQuery + select` hooks; mirror-don't-import the web design. - Wired into `(tabs)/_layout.tsx` as React Navigation's native `tabBarBadge` + `tabBarBadgeStyle`. Style is JUST `backgroundColor` (brand blue `#4571e0`); @react-navigation/elements `Badge` internally uses `borderRadius = size / 2` and `minWidth = size`, so the single-character badge renders as a true circle. Overriding minWidth / fontSize / fontWeight breaks that geometry — keep the override minimal. - Brand blue chosen over the iOS default red: matches web's ChatFab `bg-brand` pip and avoids the "error / critical" connotation red carries for an everyday new-comment notification. Both queries (`inboxListOptions`, `chatSessionsOptions`) are already kept fresh by listing-level realtime hooks mounted in `app/(app)/[workspace]/_layout.tsx` (`useInboxRealtime` / `useChatSessionsRealtime`), so badges update via WS events without a poll or focus refetch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): workspace search modal Wires the header search icon to a working modal — debounced search across issues + projects, Recent as empty state, modal-to-detail via router.replace. Behavioral parity with packages/views/search but stays search-only (no command-palette section) so it doesn't dual-list targets already in the More popover. - data/schemas.ts: SearchIssuesResponseSchema / SearchProjectsResponseSchema with enum-drift defense (match_source falls back to "title") - data/api.ts: searchIssues / searchProjects with AbortSignal forwarding and parseWithFallback - (app)/[workspace]/search.tsx: TextInput + 300ms debounce + abort, single FlatList driving Recent / Projects / Issues rows, snippet line for comment-matches mirrors web search-command.tsx:632 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): stop emoji clipping in ProjectIcon Previous impl rendered the emoji as <Text leading-none>. On iOS, emoji glyphs render ~10-15% larger than fontSize because they ignore latin baseline metrics, and <Text> clips content to lineHeight — so the top and bottom of every project emoji were being cut off. project-row.tsx had a pt-0.5 compensation that only nudged the top, leaving the bottom clipped and producing the "row height feels off" visual. Wrap the Text in a fixed square View (sm=18 / md=22 / lg=28 px), set explicit lineHeight = round(fontSize * 1.2) so the glyph has the room it needs. Drop the pt-0.5 hack — the icon now self-centers cleanly and flex parents using items-start / items-center align siblings against a stable square footprint. Affects every ProjectIcon call site: search rows, Projects list, project header card, issue attribute / create-form rows, project picker sheet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): inbox → comment deep-link with flash highlight When a user taps a new_comment / mentioned / reaction_added inbox row, the issue detail screen now auto-scrolls to the target comment and flashes it (matching web's behavior at packages/views/issues/components/ issue-detail.tsx:686-709). Replies are folded into their parent's CommentCard, so a reply deep-link scrolls to the parent row and lights up the matching child View only — mirroring web's replyToRoot fallback. - Inbox tap now uses object-form router.push with highlight + h (nonce) params so re-tapping the same row re-fires the effect. - TimelineList owns scrollToIndex (data-relative, viewPosition 0.3) with the standard onScrollToIndexFailed estimate-then-retry dance for variable-height rows. - CommentCard renders an absolute-positioned Reanimated overlay (borderWidth + bg wash for root, bg-only for reply) driven by a single sharedValue with withSequence(700ms in, 1800ms hold, 700ms out) — matching web's transition-colors duration-700 + setTimeout(2500) timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): TextField + AutosizeTextArea primitives Mobile had 16 bare <TextInput> sites and a shared <Input> component that nothing used. Every screen author repeated the four RN cross- platform workarounds independently — paddingVertical:0, includeFont Padding:false, textAlignVertical, and (for multiline) the onContentSize Change + height-state dance — and most missed at least one. This commit introduces two primitives that bake those in: - <TextField> — single-line baseline with variant="filled" (default). Locks multiline={false} + numberOfLines={1} so callers can't mix iOS UITextField / UITextView modes by accident. - <AutosizeTextArea> — multiline that actually grows with content, via onContentSizeChange → useState(height) clamp to [minHeight, maxHeight]. RN's Yoga doesn't read native intrinsicContentSize (facebook/react-native#54570, open), so this is the only way the bounding box keeps up with text. scrollEnabled flips on at the ceiling so a tall draft becomes internally scrollable instead of pushing the layout open. Migrated 8 of 16 sites — chat composer, 3 description fields (new issue, project new, project edit), and 4 picker sheets (label, project, assignee, add-resource). Comment composer migration ships in the follow-up commit since it's bundled with the redesign. login / verify / search / hero titles + variant="outlined" / size="hero" intentionally deferred (Out of Scope per plan) — no user-reported bug, add them when the migration earns its weight. <Input> is repurposed as a re-export of <TextField> so any future import-by-name resolves to a sensible primitive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): comment composer tap-to-expand two-state UX CommentComposer's previous "stacked horizontal bars" layout (replying- to chip + 7-button MarkdownToolbar + TextInput row + floating Send) looked nothing like the chat composer beside it and dominated ~120pt of vertical space on the issue detail screen even when no one was composing. Rewritten as a compact pill that taps open into a chat-composer-shaped floating card. State machine is blur-driven: - compact + tap pill → expanded, focus TextInput via useRef + rAF (autoFocus on conditional render is unreliable across iOS/Android) - expanded + onBlur + text empty + no replyingTo → collapse to compact - expanded + onBlur + has text or replyingTo → stay expanded; draft visible, user can scroll the timeline without losing context - send success resets text but does not collapse — next blur drives it, so back-to-back sends don't make the card jump In-card action row mirrors chat: @ · 📷 · 📎 left, Send right. File / image upload reuses useFileAttach and inserts the existing markdown formats (, [📎 name](url)) — no backend changes. Drops MarkdownToolbar entirely (list/checkbox/code/quote) — users can still type those by hand and the timeline renderer is unchanged. The replyingTo chip moves to a rounded pill above the card (border-b would have clashed visually with the rounded-3xl card geometry). Also fixes a pre-existing race: canSend now gates on !fileAttach. uploading so a deferred insertAtCursor can't land in an already-cleared input. Hardens canCancelReply: blur the input when reply is cleared with empty text, so the existing collapse rule fires uniformly without forcing manual keyboard dismiss. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): standardize sheets on iOS pageSheet via SheetShell The 16 Modal-based sheets in apps/mobile/ all copy-pasted the same transparent-fade + hand-drawn backdrop + maxHeight pattern from the project's first sheet. That shape is right for short action menus but wrong for content viewing / search / forms — each subsequent sheet hit its own bug (keyboard squash, FlatList clipping, useSafeAreaInsets returning 0 inside Modal, "floating" feel from transparent backdrop). Introduce SheetShell — a shared primitive wrapping Modal presentationStyle="pageSheet" + nested SafeAreaProvider + header (title + X) + safe-area-aware body. Migrate 7 misclassified sheets: session, issue-filter, assignee/label/project/project-lead pickers, add-resource. Codify the container-selection rule as CLAUDE.md Lesson #6 so the next sheet doesn't inherit the wrong shape. A-class sheets (comment-action, emoji-picker, fixed-option pickers) intentionally left alone — their content matches the original pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): show agent runs on issue detail New double-state row inside IssueHeaderCard (between title and attributes): "[👤👤👤] Working" + pulse dot when ≥1 active task, "Runs · N" when only past runs exist, hidden otherwise. Tap opens a pageSheet listing Active + Past runs with status badges and an inline Cancel button on active rows. Data layer: - api.ts: listActiveTasksForIssue (GET /api/issues/:id/active-task) and listTasksByIssue (GET /api/issues/:id/task-runs), both run through parseWithFallback + a new AgentTaskSchema (lenient enums with .catch() for forward-compat) - queries/issue-keys.ts + queries/issues.ts: activeTasks + tasks options, workspace-scoped, signal forwarded - mutations/issues.ts: useCancelTask with optimistic remove + rollback - realtime/use-issue-realtime.ts: task:* WS events now invalidate the two new task queries (in addition to detail+timeline), so the row and sheet update without polling New components: AgentActivityRow (the row), RunsSheet (built on SheetShell), RunRow (single task row, cancel action), AvatarStack (mobile-native overlapping avatars). Transcript drilldown deferred to a follow-up — past row tap is no-op in v1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): inbox swipe-to-archive + batch menu Closes the inbox archive gap on mobile — desktop made archive a first-class action (hover icon + batch dropdown) but mobile had no archive entry point at all. Adds the canonical iOS pattern: left-swipe on a row reveals a destructive Archive button, full swipe auto-fires. Header gains a three-action menu for "archive all read / completed / all" mirroring the desktop dropdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): issue detail delete via three-dot header menu Issue detail had no headerRight menu, leaving users unable to delete issues from the phone. Adds the same ActionSheetIOS pattern the project detail screen already uses: Copy link / Open on web / Delete (red, Alert-confirmed). Property edits stay on IssueHeaderCard chips — one entry per action. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): close API schema + polymorphic-actor parity gaps Three real bugs uncovered by the apps/mobile/ code review, all unprotected by parseWithFallback or by the actor/assignee polymorphism: - ActorAvatar + useActorLookup did not accept "system" actors. Inbox items with actor_type="system" (platform-triggered notifications) rendered a blank circle. Add a system glyph branch + widen the lookup signature. - AssigneeValue was narrowed to "member" | "agent", silently dropping squad assignments coming from web/desktop and preventing the user from clearing them on mobile. Widen to IssueAssigneeType and render squad assignees with a generic group glyph (no squad list query yet — picker still lists members + agents only, but Unassigned now clears squads). - Six read endpoints (getMe, listWorkspaces, listInbox, listMembers, listAgents, getIssue) returned bare fetch<T>() casts with no schema validation, violating the "API Response Compatibility" rule that installed-app architectures depend on. Add zod schemas with .loose() and enum-drift .catch() defenses, plus EMPTY_* sentinels so drift downgrades to "stale defaults render" instead of crashing the boot sequence. Also fixes the AttachmentSchema typecheck failure by adding the missing chat_session_id and chat_message_id fields (mobile schema had drifted from packages/core/types/attachment.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): simplify TextField primitive Strip the four cross-platform RN TextInput workaround comments down to the two notes that still apply. Anchor height with `h-10` instead of `paddingVertical: 0`, and inline `fontSize` to avoid NativeWind mapping to fontSize+lineHeight (RN clips descenders when lineHeight is set on iOS TextInput). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): swap tab bar icons to SF Symbols Use expo-image's `sf:` source URLs for the four tab icons (tray / checklist / bubble.left / ellipsis) instead of Ionicons. Native SF Symbols render at the iOS standard tab-bar weight and stroke, so the bar matches first-party iOS apps visually. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): always-on issue comment composer Drop the tap-to-expand pill state machine. The composer now mounts in its full form (input + @ / 📷 / 📎 / Send action row) immediately, with no compact-pill intermediate state. Tap focuses the input and opens the keyboard directly. The pill→expand pattern was added to mirror chat composer's two-state UX, but on a primary input surface like comments it is pure friction: the user always has to tap once to get the affordance they came to use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): OTP code input + resend cooldown on verify screen Replace the generic Input on the email-verify screen with a 6-slot SF-styled OTP component (`input-otp-native`). Auto-submits on the final keystroke instead of requiring a tap on the Verify button, and exposes a `clear()` ref so the input resets after a server-side rejection. Add a 60-second resend cooldown with a live countdown beneath the input, calling `auth.sendCode` on tap. Clears the previous code + error when a new code is requested. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): agent presence dots + offline banner Mirrors web's agent presence semantics (packages/core/agents/derive-presence.ts) on iOS: 3-state availability (online / unstable / offline) derived from runtime.status + last_seen_at + task snapshot, with a 30s wall-clock tick so the 5-min unstable window decays without new server data. Pure derivation imported from @multica/core/agents (whitelisted). React glue (hook + WS + UI) is mobile-owned per the Sharing Principles in apps/mobile/CLAUDE.md. Wired into 12 avatar call sites via an opt-in showPresence prop: chat-header / agent-picker / session-sheet / inbox-row / issue-row / attribute-row / create-form-attribute-row / comment-card / run-row / project lead + picker. Chat composer gets an OfflineBanner above it that stays silent during loading. Two mobile-specific tweaks vs web: - 30s tick is AppState-gated and forces a recompute on foreground resume (iOS freezes JS timers in background). - daemon:heartbeat / task:progress / task:message are explicitly skipped from the WS invalidation list — high-frequency events would burn cellular data; web already documented this footgun in use-realtime-sync.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): ambient agent-working badge in issue header Adds an always-visible "agent is working" indicator next to the issue detail Stack header — a small AvatarStack + green PulseDot that opens the Runs sheet on tap. Pairs with the existing in-card AgentActivityRow, which is the first-time discovery surface; the header badge is the ambient surface that stays put while the user scrolls the timeline (agent tasks run minutes to tens of minutes). Refactors AgentActivityRow + RunsSheet to dispatch through a shared useRunsSheetStore (Zustand), since the Stack-header tree and the page-body tree can't share local React state across that boundary on Expo Router. Rationale: Apple HIG "Progress Indicators" + agent-UX ambient status pattern. See plan /Users/qingnaiyuan/.claude/plans/ok-plan-linked-taco.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): squad @-mention support in issue composer Adds squad rows to the @-mention suggestion bar — picker / serializer / actor name lookup. Selecting a squad emits a `mention://squad/<uuid>` token; backend wakes the squad's leader. Mirrors web's mention extension (packages/views/editor/extensions/mention-suggestion.tsx): alphabetical sort, archived hidden, distinct "Squad" badge. Also adds a presence dot to the agent suggestion row in the same bar (opt-in showPresence prop on ActorAvatar, mirroring 12 other call sites on this branch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add iOS mobile client section + apps/mobile/README Adds a pointer from the root README (EN + zh) to apps/mobile/, plus a mobile-specific README covering scripts, env files, and the build-onto- your-own-iPhone path for self-hosters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): escape apostrophes in login + select-workspace copy CI lint failed on react/no-unescaped-entities. Two pre-existing JSX literals contained raw apostrophes; replace with '. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mobile): add iOS app icon (shared 1024x1024 with desktop) Adds apps/mobile/assets/icon.png (copy of apps/desktop/build/icon.png, 1024x1024 RGBA) and points the Expo config at it. Resolves the \"No icon is defined in the Expo config\" warning on prebuild / EAS build. Single-source: any brand refresh updates desktop's icon, then mirrors into apps/mobile/assets/. Expo prebuild generates every required iOS icon size from this one PNG. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): remove alpha channel from app icon iOS app icons must not have an alpha channel — transparent backgrounds can render as a blank/default icon on the device home screen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(mobile): env example documents all six build/dev scripts Previous template only mentioned the two dev:mobile* (Metro) scripts. Now lists all six commands that read .env.development.local / .env.staging, and flags the compile-time-baked gotcha: changing a value requires a re-run of an ios:* build before an installed app sees the new value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): chat tab badge stuck or self-clearing in background Two paired bugs in the auto-markRead effect: 1. A `lastMarkedRef` short-circuited every re-fire of the effect, so once a session was marked read, a subsequent chat:done arriving on the same session left the badge stuck at 1 forever. 2. With (1) gone, the effect re-fired even while the Chat tab was backgrounded (React Navigation keeps sibling tabs mounted), silently clearing unread state the user never had a chance to see. Mirror web's chat-window.tsx logic: gate on `useIsFocused()` (mobile's analogue of web's `isOpen`), and rely on has_unread itself as the dedup signal — the mutation's optimistic patch flips it false immediately, so the effect won't re-fire until the next chat:done flips it true again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): add ios:device:staging:release build script Adds a Release-configuration build path for the staging variant: pnpm ios:mobile:device:staging:release → cd apps/mobile && expo run:ios --device --configuration Release Release builds strip `expo-dev-launcher` from the binary (it's only linked in the Debug Pod configuration), so the installed app loads the embedded JS bundle directly — no "Downloading…" screen, no Metro probe, no Recently-opened launcher menu. Standalone use feels like an App Store install. The existing `ios:device:staging` (Debug) path is unchanged — it stays the daily-driver for hot-reload development. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(mobile): correct Debug-vs-Release standalone claim and env reload semantics Two corrections to docs landed earlier this branch: - The README told self-host users that ios:device:staging "runs without the Mac after the build completes." That is wrong for the Debug build it produces: every launch the embedded expo-dev-launcher probes Metro, showing a "Downloading…" / Recently-opened screen and stalling when the Mac is asleep or unreachable. Split the section into two paths and recommend the new :release variant for standalone use. - The .env.example said changing a value "requires re-running an ios:* build" and that "dev:* (Metro) alone will not refresh baked-in values." That is only true for an installed Release build. For Debug, restarting Metro is sufficient — it re-reads .env on startup and inlines the new values into the next JS bundle it serves. Rewrite the comment to distinguish the two cases. Also drop stale references to the removed ios:mobile:sim* scripts from the env example. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): adopt react-native-reusables + class-mode dark mode First wave of the RNR migration documented in apps/mobile/docs/ rnr-migration.md. The hand-written components/ui/ shell was producing a steady stream of dark-mode and sheet-handling bugs; this commit establishes the foundation that lets every subsequent screen pick up RNR-shipped components and a real theme system instead. Foundation (Phase 1): - global.css + tailwind.config.js switch to shadcn neutral CSS variables (light + dark) under :root and .dark:root, with Multica custom tokens appended. tailwind utilities resolve to hsl(var(--...)). - New lib/theme.ts mirrors the variables in TypeScript and exports NAV_THEME for React Navigation chrome. - New lib/use-color-scheme.ts wraps NativeWind's useColorScheme with expo-secure-store persistence (preference key: theme-preference, values: light/dark/system). - components.json registers shadcn CLI paths so `npx @rnr/cli add` writes to the expected aliases. metro.config.js gains inlineRem: 16. - app/_layout.tsx wraps the tree in ThemeProvider(NAV_THEME[scheme]) and mounts <PortalHost /> for RNR dialogs. - Settings → Appearance picker (three rows: Light / Dark / System, persisted) — the only product addition in this commit. Component canary (Phase 2): - button.tsx + text.tsx replaced by RNR's defaults via the CLI (uses TextClassContext to flow text variants from Button into nested Text). - 11 button call sites updated to wrap children in <Text> (the RNR convention). The old `brand` variant had zero call sites and was dropped without follow-up. Bottom navigation: - (tabs)/_layout.tsx tried NativeTabs first but rolled back to JS Tabs: NativeTabs hard-codes canPreventDefault: false on tabPress events, so the "More tap opens a sheet without navigating" pattern was unreachable. The rolled-back layout uses useColorScheme + THEME to derive active/inactive tint, fixing the dark-mode "dim selected tab" bug. - More tab intercepts tabPress and pushes /[workspace]/menu — a stack route registered with presentation: "formSheet" + sheetAllowedDetents: "fitToContents" so iOS sizes the sheet to the menu's intrinsic height (UIKit handles drag handle, swipe dismiss, blur backdrop). - The formSheet route is named `menu.tsx` rather than `more.tsx` to avoid the URL collision with (tabs)/more.tsx — both files would otherwise resolve to /[workspace]/more because (tabs) is a transparent route group. - components/nav/global-nav-menu.tsx refactored from a self-managed Modal into a plain ScrollView (no flex-1, so fitToContents can measure). Closes via router.dismiss() instead of an onClose prop. Docs / rules: - apps/mobile/CLAUDE.md adds two hard rules: "defaults first" and "iOS native > RNR > discuss" (the three-tier waterfall). - apps/mobile/docs/rnr-migration.md captures the alternatives evaluated, the three-tier component classification, the phased rollout, and the pitfalls hit during this commit. Out of scope for this wave (planned but not started): - Tier A remaining primitives (input / card / text-field / textarea) - Tier B sheets (the 18 hand-rolled Modal sheets — to be replaced one PR at a time with ActionSheetIOS / native pickers / RNR Dialog) - Tier C domain UI internal-token upgrades Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip(mobile): markdown rendering tweaks — incomplete Checkpoint commit. Markdown rendering refactor is in progress and not yet producing the full expected output; committing so it isn't lost alongside the RNR migration in the same tree. Will be finished in a follow-up before push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): simple Header + IconButton, drop ScreenHeader / ChatHeader Tab and stack screens were carrying two hand-rolled header components (ScreenHeader, ChatHeader) that reimplemented enough of UINavigationBar to ship the obvious bugs: hardcoded hex colors that didn't follow the NativeWind dark scheme, no shared dark/light token wiring, no consistent touch feedback for action buttons (Pressable + custom className per call site). This commit collapses both into one shared component family: - `components/ui/header.tsx` — slot-based (`title` / `center` / `left` / `right`) rendered in the screen's JSX. Self-handles the top safe area, uses semantic RNR tokens (`bg-background`, `text-foreground`, `border-border`) so dark mode flips via NativeWind class mode with no per-screen logic. - `components/ui/icon-button.tsx` — `<RNR Button variant="ghost" size="icon">` wrapping an Ionicon whose color falls back to `useTheme().colors.text` (the active navigation theme), so the glyph follows dark/light automatically without callers passing a color prop. - `components/chat/chat-title-button.tsx` + `chat-session-actions.tsx` — chat-specific slots that plug into the same Header (center + right) instead of the chat tab having its own complete header. Call sites: - Inbox / My Issues / Chat / more/issues — drop `<ScreenHeader>` and `<ChatHeader>`, render `<Header ...>` at the top of the screen body with the appropriate slot contents. - HeaderActions — Search / New-Issue buttons swap raw Pressable for IconButton. The previously-added Menu button is removed (redundant with the "More" tab in the bottom bar). - more/issues — was rendering both the workspace stack's native header AND its own ScreenHeader inside the screen body, so the filter button now goes onto the stack header via `navigation.setOptions({ headerRight })` and the in-body header is gone. Why the per-tab Stack approach (briefly explored) was abandoned: react-navigation's native large title is the only thing that needed a Stack per tab, and the product doesn't want collapse-on-scroll. With that gone, every dynamic header content piece (Inbox's archive menu, Chat's agent picker title) was forced through `navigation.setOptions` in a useLayoutEffect — strictly more complexity than just rendering the Header in JSX with state passed as props. Net: 349 lines removed, 208 added. Two header components deleted; two small primitives added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): resolve mc:// image URIs against attachment list before render Markdown content authored in Multica stores image references as `mc://file/<id>` rather than baking signed HTTPS URLs into the text (signed URLs expire). iOS image loader doesn't understand the `mc:` scheme, so any attachment-image in a description, comment, or chat message was raising a redbox: "No suitable image URL loader found for mc://file/...". Web already resolves this via `packages/views/editor/ attachment-download-context.tsx`: components look up the markdown URL in the issue's attachment list and use the matching `download_url`. This commit mirrors that pattern for mobile. The wiring: - `data/schemas.ts` — AttachmentListSchema + EMPTY_ATTACHMENT_LIST - `data/api.ts` — listAttachments(issueId) → GET /api/issues/:id/attachments - `data/queries/issue-keys.ts` — `attachments(wsId, id)` key - `data/queries/issues.ts` — issueAttachmentsOptions - `lib/markdown/markdown.tsx` — Markdown accepts `attachments?` and forwards to MarkdownImage - `lib/markdown/markdown-image.tsx` — looks up uri in attachments, swaps for `download_url`; unresolved URIs fall through and fail the getSize callback gracefully (16:9 muted placeholder, no redbox) - `IssueDescription` and `CommentCard` — fetch via issueAttachmentsOptions; TanStack Query dedupes so the same issue's attachment list only fires one request regardless of how many components need it - `chat-message-list` — passes `message.attachments` directly (chat messages carry their attachment list on the message record itself, distinct from the issue-scoped model) Unmatched URIs (e.g. test placeholders like `file_abc123`) now render the same muted 16:9 fallback as a 404 — never a redbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): typed ws.on<E>() + useWSSubscriptions to cut realtime boilerplate Adds WSEventPayloadMap in @multica/core/types so callers get the precise payload type per event — no more `const p = msg as IssueUpdatedPayload` boilerplate at every handler. Mobile ws-client adopts the generic signature; web's untyped on() is untouched but can opt in later. useWSSubscriptions wraps the if-ws-and-wsId-then-useEffect-cleanup template every Layer-3 realtime hook used to repeat. Each of the 8 hooks sheds ~7 lines of lifecycle scaffolding and ~30 total `as Payload` casts go away; only 1 deliberate cast stays for the cross-event onTaskEvent (task:progress has no formal payload interface yet). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): settings — profile + notifications subscreens, RNR primitives, API helpers Settings page rewritten to use RNR primitives (RadioGroup, Switch, Avatar, Separator) instead of self-drawn equivalents, removes 3 hardcoded #71717a hex colors in favor of THEME tokens, and adds Alert.alert confirmation on sign-out with destructive Button variant. Two new push subscreens under more/settings/: - profile.tsx edits name + avatar. Avatar tap opens iOS native ActionSheetIOS (Take Photo / Library / Remove) via expo-image-picker, then PATCH /api/me. - notifications.tsx 5 inbox groups + system_notifications toggle, backed by optimistic PUT /api/notification-preferences. New mobile-owned query + mutation for notification preferences mirror the web design (no runtime import — per CLAUDE.md "Mobile-owned updaters"). auth-store gets setUser action for in-memory user update after profile PATCH. ApiClient gains fetchValidated + fetchValidatedWith private helpers that collapse the fetch+parseWithFallback envelope. 4 settings-related methods migrated as canary (getMe, updateMe, getNotificationPreferences, updateNotificationPreferences); remaining 30+ read methods migrate progressively in later PRs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): inbox refactor — Mark all read, swipe UX, parity fixes Swipe-to-archive no longer auto-fires on full drag (felt aggressive, no peek, easy mistrigger on fast scroll). Now matches iOS Mail / Linear: drag reveals the red Archive button + medium haptic at threshold, user taps to commit. Auto-fire path removed; useAnimatedReaction + runOnJS bridges the UI-thread shared value to Haptics.impactAsync. Behavioral parity fixes the previous mobile inbox was missing vs web: - Mark all read action — endpoint POST /api/inbox/mark-all-read already existed server-side; mobile just never wired it. Added api.markAllInbox Read + useMarkAllInboxRead (optimistic flip read=true on non-archived) + ActionSheet menu entry as the first option. - issue:updated → patch inbox row's StatusIcon inline. Previously mobile ignored the event and showed stale status until the next inbox event refetched the list. - issue:deleted → strip orphaned inbox rows so tapping doesn't 404 on the issue detail page. - Both via a new mobile-owned inbox-ws-updaters.ts mirroring web's packages/core/inbox/ws-updaters.ts. Internal cleanup: - inboxKeys factory in data/queries/inbox.ts ({all,list}, 3-segment shape matching web). 6 inline ["inbox", wsId] strings retired across queries / mutations / realtime / useCreateIssue inbox invalidate. - Synchronous setQueryData hack (workaround for iOS push transition snapshot capturing pre-flip state) moved from inbox.tsx caller into useMarkInboxRead.onMutate. Every caller benefits, none can forget it. UX polish: - Loading state: 6 Skeleton rows (RNR, installed this PR) replacing centered ActivityIndicator. - Empty state: mail-open icon + helper text replacing bare "No inbox items." copy. - ItemSeparatorComponent ml-[60px] → ml-16 (token, aligns with avatar 36 + px-4 + gap-3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(mobile): encode helper-layer conventions + swipe & Tier C lessons CLAUDE.md grew with rules surfaced by the inbox PR + the earlier WS / API helper work, so future agents can find the helpers instead of recreating them. New section "Data layer helpers" — three rails (logic mirrors web; use existing components, don't invent primitives; use the wrapped request layer) + helper-by-helper reference (fetchValidated, fetchValidatedWith, xKeys factory shape, ws.on<E>() + WSEventPayloadMap, useWSSubscriptions, synchronous-setQueryData-before-await ordering) + a 7-step checklist for new features. Realtime strategy extended with "Cross-cutting cache patches across features" — the rule that issue:* → inbox-cache patches live in inbox-ws-updaters.ts (owned by the feature being patched), not in issues' own hook. Reconnect table updated to use inboxKeys.list(wsId). Two new Lessons: - Lesson 7: destructive swipe is reveal-only, never auto-fire; haptic via useAnimatedReaction + runOnJS at the threshold. Encoded from the inbox PR's swipe UX fix. - Lesson 8: Tier C domain components (ActorAvatar, StatusIcon, etc.) upgrade opportunistically — don't silently rewrite when you're just rendering them in a new feature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): issue detail — comment-as-modal route, hex/Pressable cleanup, API helpers Comment composer redesign (user feedback: inline always-on was clunky, keyboard avoidance bad, no room for @mention suggestion bar). The bottom of issue/[id].tsx is now a single <Button>Comment</Button>; tap pushes the new issue/[id]/new-comment modal — full screen for typing, AutosizeTextArea + MentionSuggestionBar + toolbar. Reply path goes through the same modal with parent / parentName route params, so "Reply" on a comment long-press just pushes the modal in reply mode. Comment-card long-press no longer competes with iOS native text selection: wrapped <Markdown> in a View with userSelect:'none' so the press only triggers the action sheet. Users can still copy the full comment body via the existing "Copy text" entry. issue/[id].tsx headerRight 3-dot menu switches from a hand-drawn Pressable + Ionicons (hardcoded #0a84ff/#71717a) to <IconButton>. Same hex cleanup applied to: - agent-activity-row.tsx (2× #a1a1aa → THEME.mutedForeground) - activity-row.tsx (MUTED constant deleted; SVG glyph takes stroke prop) - comment-card.tsx BRAND_RING/BRAND_WASH rgba constants gone — animated overlays now use NativeWind border-brand/50 + bg-brand/5 classes, opacity stays the only animated channel. API layer: 5 issue GET methods migrated to fetchValidated (getIssue, listTimeline, listAttachments, listActiveTasksForIssue, listTasksByIssue). Write endpoints stay on raw this.fetch per the existing mobile convention — migrating writes needs new zod schemas, defer to a follow-up PR. comment-composer.tsx deleted: orphan after the modal swap. CommentActionSheet is kept as-is — it has the quick-react emoji row (the only "add reaction" entry for comments) and already follows the correct Lesson 6 short-action card pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): close button uses <IconButton variant=secondary> Both the SheetShell (pageSheet header) and the standalone ModalCloseButton (modal Stack header) were drawing the circular grey close ✕ by hand: <Pressable> + <View bg-secondary> + <Ionicons color="#3f3f46">. Two problems with that pattern: 1. The #3f3f46 zinc-700 hex is invisible in dark mode — the icon and background both go dark, contrast collapses. 2. It bypasses RNR Button (which is exactly what an icon button is), re-implements active state, and lives outside the design system. Swap both to <IconButton name="close" variant="secondary" className="size-7 rounded-full"> — RNR Button under the hood, secondary variant carries the bg-secondary token (so dark mode flips), icon color comes from useTheme(). className locks the 28pt circular shape that Linear iOS / Things 3 use for this slot (RNR's default size="icon" is a 40pt rounded-md square box, which is a different look). One-line fix per file, no new primitive. Affects every pageSheet close button (RunsSheet, picker sheets via sheet-shell) and every modal close button (new-issue, search, new-comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): PulseDot uses brand colour, not success — running ≠ completed The agent "is working" pulse dot (shown both in the issue Stack header ambient badge and in the in-card AgentActivityRow "Working" row) was backgroundColor #22c55e — that's the success/completed token. Reading green here meant "task complete", which is the opposite of what the animation represents. Switch to THEME[scheme].brand (hsl(225 71% 58%)), matching: - mobile RunRow status text: STATUS_CLASS.running = "text-brand" - web agent-live-card.tsx:327: <Loader2 text-info animate-spin /> - Apple HIG / shadcn semantic colour convention: green = success, blue/brand = in-progress, red = destructive One-line fix in pulse-dot.tsx; both call sites (AgentHeaderBadge top-right, AgentActivityRow under the title) flip from green to brand blue together. Docstring updated to spell out the rule for future readers: DO NOT use success here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): activity ↔ web parity — start_date / squad_leader / wording Five small fixes that close the remaining gaps between mobile's activity rendering and the web equivalent in packages/views/issues/components/ issue-detail.tsx. All logic-layer; no component or container changes. - timeline-coalesce.ts: add NEVER_COALESCE_ACTIONS = {squad_leader_ evaluated}. Without it, two consecutive squad-leader evaluations from the same actor within 2 min merged into one row, dropping the second's `outcome` + `reason` audit fields. Web does this since the rule was added; mobile was missing it. - format-activity.ts: add cases for `start_date_changed` (set / remove branches) and `squad_leader_evaluated` (outcome × reason 4 branches). Before, both fell through to the default that returns the raw enum name — users saw literal `start_date_changed` / `squad_leader_ evaluated` strings in the timeline. - format-activity.ts: tighten assignee wording from "assigned NAME" to "assigned to NAME" — matches web's en/issues.json copy. - activity-row.tsx: `LeadIcon` now reuses CalendarGlyph for `start_date_changed` (same affordance as `due_date_changed`). - components/inbox/detail-label.tsx: TYPE_LABEL Record was missing `start_date_changed` — fixes a pre-existing TS error. - data/schemas.ts: EMPTY_ISSUE_FALLBACK was missing `start_date: null` — fixes the other pre-existing TS error. Both gaps had the same root cause (backend added the field, mobile didn't follow). Typecheck is now clean — no pre-existing errors remaining. Copy strings mirror packages/views/locales/en/issues.json verbatim (activity.start_date_set / squad_leader_action / etc.). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): attribute row — project picker wired + all pickers go pageSheet Issue-detail AttributeRow chip row (status / priority / assignee / label / project / due-date) had three nagging gaps. Fix them together so the whole row behaves consistently. - ProjectPickerSheet was never wired: the file existed (155 lines, ready to use) but the chip was read-only with a stale `// picker deferred until web ships one` comment. Web has had a project picker forever. Add the projectOpen state, an `onProject` handler that calls `useUpdateIssue.mutate({ project_id })`, a placeholder dimmed chip when no project is set, and mount the sheet. Mobile users can now change an issue's project. - PRIORITY_LABEL was duplicated in two places — re-declared inside priority-picker-sheet.tsx (full form `none: "No priority"`) and as a near-identical chip placeholder in attribute-row.tsx (short form `none: "Priority"`). Both now import from the single source in `lib/issue-status.ts`; attribute-row keeps a 1-key override (`PRIORITY_CHIP_LABEL = { ...PRIORITY_FULL_LABEL, none: "Priority" }`) so the chip placeholder still reads as a placeholder, not as an assigned value. - Sheet container split was inconsistent: assignee / label / project pickers used SheetShell pageSheet (slide-up from bottom), while status / priority / due-date used a centered transparent Modal card (different gesture, different position). For a chip row where users tap several pickers in succession, the inconsistency broke iOS muscle memory. Status / priority / due-date all switch to pageSheet so the whole row reads as "tap chip → slide-up sheet" uniformly. Linear iOS / Things 3 / Apple Reminders use this pattern even for short fixed lists. CLAUDE.md Lesson #6 modal container table grew a "picker-row consistency wins over per-container optimisation" carve-out so future row-of-pickers work follows the same rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): 5-tier surface elevation scale — fixes comment-bubble nested contrast + inline-code link confusion Two related fixes that share root cause: shadcn's neutral palette collapses `secondary` / `muted` / `accent` to the SAME L 96.1% value intentionally — it's a single tonal slot whose semantic name varies by use case, not three different colors. Stacking a bg-muted child on a bg-secondary parent (which is what we were doing for code/table headers inside the comment bubble) made the inner element visually disappear. Introduce a proper 5-tier elevation scale calibrated to Refactoring UI and Material 3 guidance: L 100 page bg / card / popover (page floor) L 98 surface-1 NEW (subtle elevated — comment bubbles, iOS settings-cell feel: visible boundary via radius + border, fill is almost-page) L 96.1 secondary / muted / accent (shadcn default, untouched — button hover, chips, skeleton) L 90 surface-2 NEW (nested inside surface-1 — table headers + code blocks inside comment bubbles, 8% L step over surface-1) L 84 border (was 89.8% → 84%) (visible across every tier, 6-16% darker than adjacent surface, within Refactoring UI's 5-10% guideline) Dark mirror flips the lightness direction (higher elevation = lighter): page 3.9 → surface-1 8 → secondary 14.9 → surface-2 19 → border 25. Applied across three files: - global.css + tailwind.config.js + lib/theme.ts mirror the new tokens (CSS variables, Tailwind class map, TypeScript export — they must stay in sync per CLAUDE.md §5). - components/issue/comment-card.tsx switches the bubble bg from `bg-secondary` (too prominent, same color as inner muted elements) to `bg-surface-1` (subtle, 8% lighter than inner surface-2). - lib/markdown/markdown-style.ts: - table.headerBackgroundColor + codeBlock.backgroundColor: `t.muted` → `t.surface2`, so they're framed against the bubble. - inline `code:`: REVERT 2026-05-19's `color: t.brand` workaround for upstream enriched-markdown #255. The brand-tint avoided the chip's top-heavy padding artifact but broke Refactoring UI's #1 rule (color carries semantic meaning — brand IS the link color, users reported tapping inline code thinking it was a link). Re-enable bg-chip + foreground text, matching GitHub mobile / Slack / Notion / Apple Notes. The padding artifact is the lesser evil; in surface-2 (L 90%) on surface-1 (L 98%) the chip is subtle enough that the few pixels of asymmetry are unobtrusive. The shadcn `secondary` / `muted` / `accent` tokens stay at L 96.1% unchanged — other call sites (button hover, skeleton, avatar fallback, chips) all work fine on their own and were never the problem. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(mobile): hoist "existing pattern first" to Principle 1 in UI rules So AI agents grep the codebase for an analogous component before reaching for RNR add or hand-rolling — structural fix for the pre-migration legacy (21 hand-written components, 18 sheets) that accumulated by treating each new screen as a blank slate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): align my-issues + Issues with web/desktop — squad parity, scope tabs, RNR UI - my-issues "agents" scope now uses server-side involves_user_id (MUL-2397) covering squads the user is involved in; tab label "Agents and Squads" matches web my-issues.json:14 - workspace Issues gains all / members / agents scope tabs with per-scope counts (client-side assignee_type filter mirroring issues-page.tsx:90-94), scope persists across workspace switches - both screens migrate to iOS-native SegmentedControl, IconButton + dot, Ionicons chip X, and a shared IssuesLoading skeleton — drops hardcoded #71717a and react-native-svg usage on these surfaces - new useClearFiltersOnWorkspaceChange hook + IssuesLoading component shared across both surfaces (three-occurrence threshold respected) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): migrate sheet modals to route-level pageSheet (Tier B rollout) Replaces the legacy "Modal transparent fade + hand-drawn backdrop" sheet shell with expo-router route-level pageSheet modals — the canonical container for content sheets per mobile/CLAUDE.md Lesson 6 and the Tier B section of docs/rnr-migration.md. Sheets deleted (9): chat session-sheet, comment-action-sheet, issue-filter-sheet, six issue pickers (assignee, due-date, label, priority, project, status), runs-sheet, project add-resource-sheet, project-lead-picker-sheet, plus the shared sheet-shell and runs-sheet-store that supported them. Route-level modals added: /[workspace]/{chat-sessions, issues-filter, new-issue-picker/*, issue/[id]/{runs, picker/*, comment/[commentId]/actions}, project/[id]/{add-resource, picker/lead}}. Each picker is split into a thin route file + reusable *-picker-body.tsx so the same body composes inside the new-issue draft form and the issue-detail attribute row. Comment CRUD endpoints (update / delete / resolve / unresolve) + matching optimistic mutations + CommentSchema added to support the new comment actions route. Two new draft/picker stores carry session-scoped state for the chat-session picker and the new-issue form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(mobile): markdown rendering ADR + selectable carve-out Formalises the rendering decision (Path B — react-native-markdown-display + Shiki + custom renderers) into a one-page ADR with A-tier source citations, keeping the longer research log alongside it. Adds a `selectable` opt-out to `CodeBlock` and `Markdown` so timeline comments can disable RN's UIKit selection magnifier when an outer Pressable already owns the long-press gesture, while issue descriptions and chat messages keep the default selectable behaviour for copy-to-clipboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): add inline titles to 5 issue picker bodies SHEET_OPTIONS sets headerShown: false so every formSheet body must draw its own title. Five issue pickers (status / priority / assignee / label / project) were shipping headerless; only due-date had a title. Inline a single header row in each body — five callers, no shared primitive (3x rule not triggered). * feat(mobile): full emoji picker for comment reactions via formSheet route Mobile now offers the full emoji set behind a 'More reactions' overflow in the per-comment actions sheet, matching web's emoji-mart parity. - Adopt rn-emoji-keyboard 1.7.0 (zero runtime deps, React 19 / RN 0.83 compatible, installed via expo install). - New formSheet route at issue/[id]/comment/[commentId]/emoji-picker.tsx embeds EmojiKeyboard inline so UISheetPresentationController retains grabber, detents, and drag-to-dismiss. - Quick-row overflow '+' button in comment actions pushes the new route. - Delete the dead emoji-picker-sheet.tsx and the unused emojiPickerOpen state in comment-card.tsx (never opened from anywhere after the actions-route migration). - Move QUICK_EMOJIS to lib/quick-emojis.ts since its old host file is gone. - Update rnr-migration.md B.4 to record the resolution. * feat(mobile): project status + priority pickers via formSheet routes Project detail's Status and Priority chips were the last two picker chips still using the legacy centered-Modal pattern. The mixed gesture (Status/Priority popped a centered card; Lead / Add Resource slid up a formSheet) violated the picker-row consistency rule in CLAUDE.md Lesson 6 — the four chips on the same row now all open the same way. - New picker bodies under components/project/pickers/. - New formSheet routes under app/(app)/[workspace]/project/[id]/picker/. - Register both screens in workspace _layout.tsx using SHEET_OPTIONS. - project/[id].tsx: drop the local state, swap chip onPress to router.push, and remove the trailing 'still uses transparent-Modal' apology comment. - project/new.tsx is a draft modal so it can't push to a route (no project exists yet to read from cache). Inline a tiny DraftPickerModal shell that hosts the same picker bodies — documented in the file. - Delete the obsolete ProjectStatusPickerSheet / ProjectPriorityPickerSheet files and update rnr-migration.md to reflect that B.2 is closed. * refactor(mobile): menu sheet uses shared SHEET_OPTIONS Drop the bespoke 'fitToContents' branch for menu.tsx. Every other formSheet uses [0.6, 0.95] explicit detents to dodge the iOS 26 + Expo 55 fitToContents bugs (expo/expo#42904, #42965). Keeping menu on the unsafe API solely because it 'shipped first' was a divergence without a current reason — the bugs apply to it too. SHEET_OPTIONS is now the single source of truth for every sheet. CLAUDE.md Lesson 6 rationale updated to match. * fix(mobile): reset cross-route draft stores on workspace change Both useNewIssueDraftStore and useChatSessionPickerStore hold workspace-scoped state (assignee ids, draft session ids) that points at records in the workspace that seeded them. Switching workspaces left that state in place — a draft assignee from workspace A would survive into workspace B's new-issue modal, where the id resolves to nothing. Add a reset() to chat-session-picker-store (new-issue-draft-store already had one) and expose a use…ResetOnWorkspaceChange(wsId) hook from each store file. Wire both hooks once from workspace _layout.tsx so the reset fires on every transition between matched workspace ids. Docblocks updated to record where the reset is wired (single source of truth: workspace _layout.tsx). * fix(mobile): typed picker pathname maps replace 'as never' router.push attribute-row.tsx and create-form-attribute-row.tsx built the formSheet route pathname via template strings cast 'as never', which silently accepted any field name. Typos would compile and only blow up at runtime with a 'no matching route' that's easy to miss in dev. Introduce per-row IssuePickerField / NewIssuePickerField union types mapped to literal-typed pathname records (with 'satisfies' to keep the record exhaustive). Any new picker field is now a compile error until both the union and the map are updated together. Verified: changing 'priority' to 'pirority' at a call site now produces TS2345 instead of compiling silently. * fix(mobile): cold-start anchor for formSheet deep links Without unstable_settings.anchor, a deep link or notification that targets a formSheet route (issue/[id]/picker/status, etc.) cold-starts the app onto the sheet alone — no parent screen, swipe-down lands the user on a blank canvas. Anchor: '(tabs)' tells Expo Router to mount the tab UI as the implicit base, so dismissing the sheet always returns to a sensible workspace home. Set on the workspace _layout.tsx that owns every formSheet route registration. The root (app)/_layout has no formSheet declarations so no anchor is needed there. * refactor(mobile): new-project draft store + formSheet pickers Replaces the one-off DraftPickerModal (RN <Modal transparent fade> + centered card) in project/new.tsx with the same cross-route draft-store + formSheet picker route pattern as new-issue. Status / priority chips now push /new-project-picker/<field> like the new-issue chips do, and the picker bodies are reused as-is. Removes the last hand-rolled modal sheet introduced after the Lesson 6 formSheet migration — keeping the rule "every sheet is a formSheet route" intact across the codebase. * fix(mobile): make first mount a true no-op in draft-store reset hooks The two cross-route draft store reset hooks (new-issue, chat-session) documented their first mount as "effectively a no-op" but the implementations stomped the store on every workspace-id transition including the initial null → uuid resolve. That's harmless when the store is already INITIAL but contradicts the docblock and would corrupt any future code that pre-seeds the store before navigation lands. Gate the reset() call on a useRef-tracked previous id so it only fires on genuine transitions. Matches the new-project-draft-store hook added in the prior commit so all three stores follow one shape. * fix(mobile): menu sheet keeps fitToContents detent The Tier B sheet migration swept menu.tsx into shared SHEET_OPTIONS, which set sheetAllowedDetents=[0.6, 0.95]. That's right for picker-row sheets where consistency across neighbour chips matters, but the menu is an isolated sheet (≤ 5 fixed actions, opened from the tab bar) — the two-snap default leaves ~60% of the sheet blank. Override sheetAllowedDetents to "fitToContents" for menu only, and amend the SHEET_OPTIONS rationale in apps/mobile/CLAUDE.md so the rule is spelled out: picker-row sheets share the explicit detents for muscle-memory carry-over; isolated sheets shrink-wrap. * fix(mobile): align picker search box to title (px-4) The three search-bearing picker bodies (assignee / label / project) had title rows at px-4 and search boxes at px-3 — a 4px misalignment where the search field's leading edge sat outside the title's leading edge. Bring the search container to px-4 so the title text, the search placeholder, and the search input all share one vertical baseline. Status / priority / due-date pickers have no search box (and so no misalignment); project-detail lead picker has no title row (search box defines its own px-3 baseline), both intentionally unchanged. * feat(mobile): mirror web project progress section in header card Adds a horizontal progress bar driven by `done_count / issue_count` plus a "X / Y · NN%" label, hidden when issue_count is zero (no info to show + divide-by-zero hazard). Mirrors web's project-detail.tsx 596-620 to satisfy behavioral parity — web users see project progress in the project header, mobile users should too. Note: this change was added autonomously by the code-review follow-up agent outside the original 6-item review scope. Code quality is sound (token-based colors, zero-count guard, web source referenced inline) so kept rather than dropped, but flagged here for traceability. * feat(mobile): project surface v1 — Board view, hex/SVG sweep, planning docs Closes the remaining items from project-v1-plan.md: - View mode switcher (List / Board) on project detail's related-issues: - List mode regrouped into full BOARD_STATUSES (backlog / todo / in_progress / in_review / done / blocked), replacing the mobile-only "Open / Done" two-bucket rollup that silently diverged from web's six-bucket grouping (parity violation, gap audit §3) - Board mode: horizontal scroll, one status column per group, each column is a FlatList of IssueRow (reuses existing primitive) - View mode is local useState — no Zustand store (single component scope, mobile/CLAUDE.md "no state unless required") - Hex sweep → THEME tokens / NativeWind semantic classes (gap audit §5): project-properties-section, project-resources-section, project/[id], more/projects. Eliminates the last project-domain dark-mode breakage. - Hand-drawn SVG icons → existing primitives (gap audit §6): more/projects PlusButton → <IconButton name="add"> project-properties-section chevron → <Ionicons name="chevron-forward"> project-related-issues chevron → <Ionicons name="chevron-forward"> Drops react-native-svg where no longer used. Items 1 / 2 / 4 (Tier B picker migration, progress section, new-project draft persistence) landed in preceding commitsc644e2a3,7337206f,2ff95c34. With this PR the full project-v1-plan is implemented and the two planning docs (gap audit + implementation plan) are committed for future reference. * refactor(mobile): drop project board (kanban) view, keep list-only Mobile intentionally diverges from web's Board / List view selector and ships only the status-grouped list. Reasons (now documented in the file docblock): - Phone screens are too narrow to show ≥3 status columns at once, defeating kanban's core "see pipeline at a glance" value — users end up swiping between near-empty columns. - Major mobile task apps (Linear iOS, Things, Apple Reminders) don't ship kanban; list with status grouping is the established small-screen pattern. - mobile/CLAUDE.md "Behavioral parity" permits UI divergence when semantics agree. Same issues, same status enum, same 6 BOARD_STATUSES grouping — only the layout differs. What stays from the prior plan: - Full BOARD_STATUSES grouping (backlog / todo / in_progress / in_review / done / blocked) — the real parity fix replacing the earlier mobile-only "Open / Done" two-bucket rollup. Cancelled remains hidden on both clients. What's removed: - BoardView component + horizontal ScrollView - View mode SegmentedControl + ViewMode local state - BoardView's column-empty placeholders The `@react-native-segmented-control/segmented-control` dependency is kept — my-issues and more/issues still use it for scope tabs (Mine / All / Agents) where semantics also vary on web. * feat(mobile): More tab opens dropdown popover anchored above the tab Tapping the More tab now opens a small DropdownMenu popover containing the user card, workspace switcher, and secondary nav (Issues/Projects) — anchored directly above the tab button. Replaces the previous listeners.tabPress that pushed /menu as an iOS formSheet, which felt heavy for a quick switch. Implementation: - Add @rn-primitives/dropdown-menu and a shadcn-style wrapper at components/ui/dropdown-menu.tsx (Root/Trigger/Portal/Overlay/Content/ Item/Label/Separator using semantic tokens — bg-popover, accent, border — matching the existing button.tsx pattern). - New MoreTabDropdownAnchor (components/nav/more-tab-dropdown.tsx) mounts as a sibling to <Tabs> at the workspace tabs layout. It is absolute-positioned over the More tab's screen rect (right 25%, bottom = safe-area inset, height = 49) with pointerEvents="box-none" so taps pass straight through to the real tab button. The Trigger inside is an invisible Pressable; opened imperatively via TriggerRef.open() from listeners.tabPress on the More tab. The @rn-primitives Trigger measures its own rect inside open(), so the popover anchors correctly without manual screen-width math. - The /menu formSheet route stays registered in [workspace]/_layout.tsx as a dead path for now (reversibility); to be removed once the popover bakes in. Rejected alternative: replacing the More tab's tabBarButton with a custom DropdownMenuTrigger wrapper. RN's BottomTabItem wraps the returned button in <View style={{flex:1}}> and expects a single Pressable; introducing the DropdownMenu Root as an extra wrapping View broke the flex layout and stripped the "More" label. The Option B pattern here leaves the real tab button entirely untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): swap SegmentedControl for RNR Tabs; drop bg-popover from sheet contents - Add components/ui/tabs.tsx (RNR Tabs primitive wrapper on @rn-primitives/tabs, shadcn-style API). - My Issues and the More > Issues page swap iOS SegmentedControl for the new RNR Tabs — consistent visual with the rest of the RNR components and gives count-suffix labels room to breathe. - Switch the shared SHEET_OPTIONS contentStyle from height: "100%" to flex: 1 — works for both fixed-detent and fitToContents sheets, whereas the explicit 100% height pre-empted flex behaviour in the fitToContents case. - Drop the explicit `bg-popover` background from sheet root Views (chat-sessions, issues-filter, runs, comment actions/emoji-picker, add-resource). The iOS formSheet container already paints the popover surface; an inner bg-popover stacked on top showed as a subtle double-layer when detents animated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): native iOS assignee picker — search bar + pin selected + checkmark accessory - Switch assignee picker (issue + new-issue) from body-rendered header to native Stack header + UISearchController via headerSearchBarOptions. - Body becomes pure FlatList — fixes react-native-screens#3634 overlap (FlatList now route's direct child, no intermediate wrapper view). - Pin currently-selected actor + Unassigned to the top when no query; search results stay in member → agent → squad order. - Inline right-aligned "Agent" / "Squad" tag mirrors Apple's Value-1 cell style (UIListContentConfiguration.valueCell) used throughout Settings. - Selection indicator: Ionicons checkmark in primary tint only, no row bg highlight (Apple HIG: never use selection to indicate state). - Avatar 28pt → 36pt. - autoFocus on search bar for search-first pickers — keyboard appears on mount, opt-in via hook option. - Extract useNativeSearchBar + useScrollToTopOnChange hooks under apps/mobile/lib/ for phase-2 rollout to label / project / lead pickers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip(mobile): in-flight comment-select / chat / markdown work Batch commit of pre-existing uncommitted work carried forward alongside the assignee picker refactor. Topics mixed — split into proper atomic commits when each lands. - apps/mobile/data/comment-select-store.ts: new comment-selection store - components/issue/comment-card.tsx + issue/[id].tsx + comment actions: comment-select wiring - components/chat/chat-message-list.tsx: chat list rework (~170 lines) - lib/markdown/markdown.tsx: markdown adjustments - package.json + pnpm-lock.yaml: dependency drift Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mobile): EXPO_BUNDLE_IDENTIFIER override + brand logo + CLAUDE.md preflight rules - .env.example + app.config.ts: optional EXPO_BUNDLE_IDENTIFIER for devs whose Apple ID isn't on the Multica team - components/brand/multica-logo.tsx: new brand logo asset - CLAUDE.md: restructured with mandatory pre-flight (read web impl → show plan → wait for go) before any new mobile feature; consolidated behavioral parity rules Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mobile): friendlier auth error messages on login + verify Adds lib/auth-error.ts that maps backend raw English errors (invalid / expired / rate-limited / network) to user-facing copy. login.tsx and verify.tsx route their catch blocks through it with a per-screen fallback string. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mobile): markdown rendering + UI primitive polish - lib/markdown/{code-block,markdown-style,preprocess}: refined code block rendering, restructured style map, preprocess tweaks - components/ui/{actor-avatar,text-field}: visual polish - components/issue/mention-suggestion-bar: tweaks alongside inline composer mention pipeline - components/editor/use-file-attach: small adjustments Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): picker polish + inline label create with deterministic color - New labels mutation (data/mutations/labels.ts) + createLabel API method (data/api.ts) so the label picker can create-and-attach in one flow without leaving the sheet - lib/inline-color.ts: deterministic palette hash ported from packages/views label-picker for behavioral parity (same name → same color across web/mobile) - All issue + project picker bodies (label/priority/status/project on issues; lead/priority/status on projects) reworked for visual + interaction consistency - Picker route shells (issue/[id]/picker/{label,project}, new-issue-picker/project, project/[id]/picker/lead) updated to match Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): drop menu route + global-nav-menu, dropdown only The More-tab dropdown popover (introduced earlier) now covers everything the dedicated /menu route and global-nav-menu component used to render. Drop both. The Stack.Screen registration for the menu route in (app)/[workspace]/_layout.tsx is removed in the follow-up comment-surface commit alongside other dead route registrations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): comment surface — inline composer + UIKit context menu + failed-retry + last-viewed divider Replaces the old route-based comment composition + actions sheet with surface-level UI that matches iMessage / Slack iOS / Telegram conventions. Long-press on a comment bubble now hands the gesture to UIKit's UIContextMenuInteraction (via react-native-ios-context-menu) — system blur, snapshot scale, grouped menu (Reply / Edit / Copy / Select Text / Copy Link / Resolve / New Issue / Delete), and a Tapback-style auxiliary preview emoji row above the snapshot. Eliminates the race between Pressable.onLongPress and UITextView's selection magnifier that the old formSheet route suffered from. New inline composer (components/issue/inline-comment-composer.tsx) sits at the bottom of the issue detail screen, pinned just above the keyboard via KeyboardStickyView (react-native-keyboard-controller). Replaces the new-comment.tsx modal route — phone keyboard already gives the composer dedicated real estate, the route + draft store were overhead. Timeline gains: - "New since last view" divider driven by data/stores/last-viewed-store.ts - Failed-comment retry/discard inline affordance backed by data/stores/failed-comments-store.ts (mutation onError keeps the optimistic entry; this store carries retry metadata + error string) Data layer: - mutations/issues: useCreateComment accepts attachmentIds, mirrors web's activeIds derivation - realtime/issue-ws-updaters + use-issue-realtime: WS coverage tweaks for new comment lifecycle - comment-select-store: extended for the Select Text path triggered from the new context menu Cleanup of dead route registrations (workspace _layout.tsx) for the removed new-comment, comment/actions, and (already-removed) menu routes. Adds deps: react-native-ios-context-menu, react-native-ios-utilities, react-native-keyboard-controller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): More popover — pins + workspace switcher - Pins: pin issues/projects from the header three-dot menu; Pinned list in the More popover; mirrors web's pin endpoints + cache shapes. Adds data/queries/pins.ts, data/mutations/pins.ts, realtime updater, PinListSchema + EMPTY_PIN_LIST fallback. - Workspace switcher: collapse the per-workspace list in the More popover down to a single WorkspaceCard row + pushes a dedicated switch-workspace formSheet with an iOS Alert.alert confirm before actually switching. Adds friction against accidental taps and keeps the popover short. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): comment + chat long-press → ActionSheetIOS, composer pill↔expanded - Comment long-press: drop react-native-ios-context-menu UIContextMenu wrapper in favour of native ActionSheetIOS via a useCommentLongPress hook. Removes two native deps (react-native-ios-context-menu + react-native-ios-utilities). The "Select text" path still works — toggling useCommentSelectStore swaps the bubble's long-press handler for selectable text. - Comment composer: two visual states. Collapsed = pill placeholder ("Add a comment, @ to mention…"). Expanded = TextInput + toolbar (📎 attach · ➤ send). Adds reply-target-store driven by the long-press "Reply" action and an attachment row (composer-attachment-row + comment-attachment-list mirror web's data contract). - Chat: matching ActionSheetIOS long-press (Copy / Select Text / Cancel) via message-long-press + chat-select-store; cleared on tab blur via useFocusEffect. - useMentionInput.setText now accepts the React functional updater so post-await replacements (upload placeholder → final markdown) don't lose the user's intermediate typing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(mobile): list parity polish + drop new-issue seed params - my-issues / more issues: drop the RNR Tabs primitive in favour of plain Pressable pills (Tabs adds vertical padding + a divider that break under the cramped 375pt SE3 layout). "Agents and Squads" pill label trimmed to "Agents" — backend predicate unchanged (involves_user_id), empty-state copy still mentions "agents or squads". Scope counts dropped from pill labels (web's IssuesHeader doesn't show them either, and "(123)" suffix overflowed on SE3). - issue-row: render assignee whenever assignee_type + assignee_id are both truthy. Earlier whitelist (member/agent only) silently dropped squad assignees; ActorAvatar already handles all four enum values. - new-issue: remove unused seed_content / seed_actor route params — the comment-action-sheet path that fed them no longer exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(mobile): tighter markdown code sizing + auth layout - Markdown: inline code 15→14 (match body) and block code 14→13 + leading-5. SF Mono is denser than PingFang at the same point size, so the +1 inline bump made mono glyphs visibly larger than surrounding Latin text; the new sizing matches GitHub Mobile / Linear iOS / Notion iOS. The two paths (CodeBlock vs enriched list-nested code) now agree on 13px. - Login + verify: logo 56→32, title text-3xl bold → text-2xl semibold, description text-base → text-sm, outer gap-8 → gap-6, brand cluster gap-4/2 → gap-3/1. Brings the auth screens in line with iOS native Settings / Things 3 / Linear iOS layouts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mobile): fresh-checkout build path — simulator scripts, env consistency - Track apps/mobile/.env.staging (root .gitignore was swallowing it despite mobile gitignore claiming it was committed). Fresh checkouts can now run *:staging without copying the template first. - Rename EXPO_BUNDLE_IDENTIFIER → EXPO_BUNDLE_IDENTIFIER_DEV and apply only in the dev variant of app.config.ts. Expo CLI auto-loads .env.development.local on every run regardless of APP_ENV, so a generic name silently leaked a dev's personal bundle id into staging / production builds and collapsed the three variants onto one id. The _DEV suffix + isDev-only branch keeps each variant on its canonical id. - Add ios:mobile / ios:mobile:staging scripts (root + apps/mobile package.json) so the iOS Simulator path exists end-to-end. Previously the only documented build commands targeted USB devices. - Rewrite apps/mobile/README.md: 6-row command table, first-time setup section (.env.development.local copy step, EXPO_BUNDLE_IDENTIFIER_DEV note), explicit simulator section, clarify 7-day signing limit applies to device builds only. - Update root CLAUDE.md mobile commands block to list both simulator and device commands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mobile): prod build path + composer/mention/edit polish Prod build path — lets external users self-build a personal copy against api.multica.ai's production backend: - New `prod` variant alongside `dev` / `staging`: `.env.production`, `dev:prod` / `ios:device:prod` / `ios:device:prod:release` scripts - `EXPO_BUNDLE_IDENTIFIER_PROD` shell override in `app.config.ts` for contributors not on the Multica Apple Developer team (parallel to existing `_DEV` pattern) - Public docs page `mobile-app.{mdx,zh.mdx}` + Reference entry; README gains a top-of-file "Just want to use it" section Composer refactor: - Shared `components/composer/message-composer.tsx` shell removes ~400 lines of duplication between chat-composer and inline-comment-composer - Mention picker pulled out of inline modal into a Router formSheet route (`mention-picker.tsx` + `pickers/mention-picker-body.tsx`), backed by a Zustand `mention-draft-store` Other: - Issue edit screen (`issue/[id]/edit.tsx`) + reusable description-field - Chat empty-state and timeline split into dedicated components; status-pill / message-list / attachment-row rewrites - Markdown render tweaks, `lib/format-elapsed.ts`, `ui/collapsible.tsx` - Realtime / schemas additions for chat session updates; new mention-picker stack screen registered in workspace `_layout.tsx` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(mobile): rewrite self-build framing + fix latent CI errors Docs: drop the "Multica Apple Developer team" framing (no such team) — every contributor signs the default bundle id with Xcode's free Personal Team; the EXPO_BUNDLE_IDENTIFIER_PROD override is just a fallback for the rare case where the prefix gets squatted in Apple's developer portal. Touched: - apps/mobile/README.md (top "Just want to use it" section) - apps/docs/content/docs/mobile-app.{mdx,zh.mdx} CI: latent type / lint errors that the prior install-step failure had been masking — surfaced once dependencies installed cleanly: - failure-reason-label.ts / run-row.tsx — add the new codex_semantic_inactivity enum key from packages/core/types/agent.ts - schemas.ts UserSchema + EMPTY_USER — add profile_description, timezone - schemas.ts EMPTY_ISSUE_FALLBACK — add metadata - profile.tsx — escape apostrophe in JSX text Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 lines
43 B
TypeScript
2 lines
43 B
TypeScript
/// <reference types="nativewind/types" />
|