Compare commits

...

116 Commits

Author SHA1 Message Date
Naiyuan Qing
fe1140c10a 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>
2026-05-22 19:08:48 +08:00
Naiyuan Qing
e113308435 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>
2026-05-22 18:30:43 +08:00
Naiyuan Qing
478ed502c3 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>
2026-05-22 18:12:56 +08:00
Naiyuan Qing
36ac5724d7 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>
2026-05-22 15:18:25 +08:00
Naiyuan Qing
a69abda373 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>
2026-05-22 15:18:14 +08:00
Naiyuan Qing
de79037d1f 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>
2026-05-22 15:18:02 +08:00
Naiyuan Qing
e87331e0fc 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>
2026-05-22 15:17:25 +08:00
Naiyuan Qing
f3c4e6686a Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-22 07:59:40 +08:00
Naiyuan Qing
901bb11e3b 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>
2026-05-21 18:53:07 +08:00
Naiyuan Qing
4a12105bf1 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>
2026-05-21 18:52:40 +08:00
Naiyuan Qing
9e4238648e 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>
2026-05-21 18:52:27 +08:00
Naiyuan Qing
0d1601be8b 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>
2026-05-21 18:52:06 +08:00
Naiyuan Qing
c09a096c2d 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>
2026-05-21 18:51:55 +08:00
Naiyuan Qing
0948041f26 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>
2026-05-21 18:51:42 +08:00
Naiyuan Qing
b5913b0a0e 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>
2026-05-20 19:14:02 +08:00
Naiyuan Qing
c19bf6612a 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>
2026-05-20 19:13:50 +08:00
Naiyuan Qing
6166a0b6a6 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>
2026-05-20 15:04:00 +08:00
Naiyuan Qing
fe62f7f5b2 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>
2026-05-20 15:03:48 +08:00
Naiyuan Qing
e6c296deb3 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.
2026-05-20 14:05:46 +08:00
Naiyuan Qing
e6f175e752 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 commits c644e2a3, 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.
2026-05-20 13:51:17 +08:00
Naiyuan Qing
2ff95c3413 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.
2026-05-20 13:37:07 +08:00
Naiyuan Qing
2cb2016235 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.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
892df00b54 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.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
2a6b4bf641 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.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
7337206f9e 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.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
d77d13f23b 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.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
ef8f24c095 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.
2026-05-20 11:27:06 +08:00
Naiyuan Qing
e5ee63aa1d 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).
2026-05-20 11:21:18 +08:00
Naiyuan Qing
ac4507bcdd 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.
2026-05-20 11:17:15 +08:00
Naiyuan Qing
c644e2a338 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.
2026-05-20 11:15:25 +08:00
Naiyuan Qing
414c3b74a9 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.
2026-05-20 11:07:34 +08:00
Naiyuan Qing
4ebdb69e7e 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).
2026-05-20 10:59:42 +08:00
Naiyuan Qing
383dd23c36 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>
2026-05-20 10:50:12 +08:00
Naiyuan Qing
cc61ae3a0a 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>
2026-05-20 10:49:51 +08:00
Naiyuan Qing
7f33ae7eec 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>
2026-05-20 10:42:12 +08:00
Naiyuan Qing
53ee5be68a 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>
2026-05-20 10:41:40 +08:00
Naiyuan Qing
06cb4674e0 Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-19 18:12:42 +08:00
Naiyuan Qing
08a8f386d7 Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-19 17:34:36 +08:00
Naiyuan Qing
dbb8c0ade0 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>
2026-05-19 17:32:41 +08:00
Naiyuan Qing
27c171f30c 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>
2026-05-19 17:32:15 +08:00
Naiyuan Qing
b39e3a90c0 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>
2026-05-19 17:31:57 +08:00
Naiyuan Qing
e9b2e98d15 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>
2026-05-19 16:12:41 +08:00
Naiyuan Qing
9aaff146b5 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>
2026-05-19 16:12:23 +08:00
Naiyuan Qing
95979515b2 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>
2026-05-19 16:12:09 +08:00
Naiyuan Qing
fcd99c0084 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>
2026-05-19 15:08:21 +08:00
Naiyuan Qing
ace56f80c8 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>
2026-05-19 15:08:07 +08:00
Naiyuan Qing
f5dbcad533 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>
2026-05-19 14:34:43 +08:00
Naiyuan Qing
0de3d4012c 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>
2026-05-19 14:34:23 +08:00
Naiyuan Qing
abd9af96d3 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>
2026-05-19 13:35:43 +08:00
Naiyuan Qing
125151e2c4 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>
2026-05-19 13:35:23 +08:00
Naiyuan Qing
8996e821b9 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>
2026-05-19 11:09:20 +08:00
Naiyuan Qing
420230c62a 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>
2026-05-19 11:09:00 +08:00
Naiyuan Qing
ca9004961d Merge remote-tracking branch 'origin/main' into feat/mobile-ios
# Conflicts:
#	pnpm-lock.yaml
2026-05-19 08:37:02 +08:00
Naiyuan Qing
e4ecb722fe 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>
2026-05-15 21:27:54 +08:00
Naiyuan Qing
de235fd5fd 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>
2026-05-15 21:27:31 +08:00
Naiyuan Qing
87cc3093ab 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>
2026-05-15 18:35:01 +08:00
Naiyuan Qing
e8c4c87741 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>
2026-05-15 18:21:30 +08:00
Naiyuan Qing
3d0c87d84f 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>
2026-05-15 18:21:24 +08:00
Naiyuan Qing
48f912c3ea 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>
2026-05-15 17:01:17 +08:00
Naiyuan Qing
332ac868ce 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 &apos;.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:54:44 +08:00
Naiyuan Qing
7bd13c83c0 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>
2026-05-15 16:49:14 +08:00
Naiyuan Qing
8811f72bc2 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>
2026-05-15 16:49:05 +08:00
Naiyuan Qing
e746b1b3bd 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>
2026-05-15 16:48:45 +08:00
Naiyuan Qing
48282fa973 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>
2026-05-15 16:48:32 +08:00
Naiyuan Qing
7c00f88f68 Merge remote-tracking branch 'origin/main' into feat/mobile-ios
# Conflicts:
#	pnpm-lock.yaml
2026-05-15 16:03:29 +08:00
Naiyuan Qing
38313a62f7 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>
2026-05-15 16:02:51 +08:00
Naiyuan Qing
db83cf9bfb 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>
2026-05-15 16:02:40 +08:00
Naiyuan Qing
0ea776e14e 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>
2026-05-15 16:02:29 +08:00
Naiyuan Qing
5a196225b8 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>
2026-05-15 16:02:21 +08:00
Naiyuan Qing
eeac71233e 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>
2026-05-15 16:00:09 +08:00
Naiyuan Qing
794c3a1a98 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>
2026-05-15 15:23:03 +08:00
Naiyuan Qing
c1452c7330 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>
2026-05-15 15:22:45 +08:00
Naiyuan Qing
11913d18f4 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>
2026-05-15 15:11:12 +08:00
Naiyuan Qing
f9cab33d94 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>
2026-05-15 15:11:00 +08:00
Naiyuan Qing
6519e23282 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 (![](url), [📎 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>
2026-05-15 14:55:23 +08:00
Naiyuan Qing
af2369002c 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>
2026-05-15 14:55:03 +08:00
Naiyuan Qing
233fdd66bc 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>
2026-05-15 14:54:40 +08:00
Naiyuan Qing
542e6b461c 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>
2026-05-15 13:27:23 +08:00
Naiyuan Qing
7aad870f06 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>
2026-05-15 13:27:13 +08:00
Naiyuan Qing
2a3accb5d7 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>
2026-05-15 10:54:21 +08:00
Naiyuan Qing
235c9e82e2 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>
2026-05-15 10:53:44 +08:00
Naiyuan Qing
f7e56577ab 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>
2026-05-15 10:53:20 +08:00
Naiyuan Qing
caa9c4946e 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>
2026-05-15 10:52:53 +08:00
Naiyuan Qing
f53a6936e3 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>
2026-05-15 10:51:36 +08:00
Naiyuan Qing
d42922020c 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>
2026-05-15 10:50:52 +08:00
Naiyuan Qing
c7eb366242 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>
2026-05-15 10:02:50 +08:00
Naiyuan Qing
69bd280695 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 in 5cc7f01 (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>
2026-05-15 10:02:31 +08:00
Naiyuan Qing
34a89f20dd 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>
2026-05-15 10:01:19 +08:00
Naiyuan Qing
5cc7f0178b 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>
2026-05-15 09:21:45 +08:00
Naiyuan Qing
ad9ba9d790 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>
2026-05-14 17:22:26 +08:00
Naiyuan Qing
8285e90d78 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>
2026-05-14 16:02:17 +08:00
Naiyuan Qing
3cb4610304 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>
2026-05-14 15:01:51 +08:00
Naiyuan Qing
3ca160c545 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>
2026-05-14 14:43:53 +08:00
Naiyuan Qing
c24e086612 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>
2026-05-14 14:38:30 +08:00
Naiyuan Qing
fdc9f916f2 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>
2026-05-14 14:25:24 +08:00
Naiyuan Qing
d2cee9741c 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>
2026-05-14 14:19:43 +08:00
Naiyuan Qing
6067a7a0d6 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>
2026-05-14 13:51:10 +08:00
Naiyuan Qing
ee1c040071 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>
2026-05-14 13:48:32 +08:00
Naiyuan Qing
2ac2609809 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>
2026-05-14 13:34:23 +08:00
Naiyuan Qing
da1c7d5bc2 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>
2026-05-14 13:21:48 +08:00
Naiyuan Qing
3a439d97a1 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>
2026-05-14 13:06:47 +08:00
Naiyuan Qing
a5c5b955df chore(mobile): merge main into feat/mobile-ios
Resolve pnpm-lock.yaml by regenerating from main + branch package.json.
2026-05-14 09:25:55 +08:00
Naiyuan Qing
0406ae3c45 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>
2026-05-11 19:12:05 +08:00
Naiyuan Qing
9718768a4f 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>
2026-05-11 17:21:11 +08:00
Naiyuan Qing
230e939e53 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>
2026-05-11 17:20:46 +08:00
Naiyuan Qing
bc4594b091 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>
2026-05-11 16:47:57 +08:00
Naiyuan Qing
ab1fe4fa1f 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>
2026-05-11 16:33:26 +08:00
Naiyuan Qing
d23d786724 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>
2026-05-11 15:27:50 +08:00
Naiyuan Qing
6239f2c1f5 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>
2026-05-11 14:45:42 +08:00
Naiyuan Qing
90fd3ff86c 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>
2026-05-09 18:07:04 +08:00
Naiyuan Qing
48c1e3746e 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>
2026-05-09 15:02:14 +08:00
Naiyuan Qing
bb281e7dbd 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>
2026-05-09 14:11:30 +08:00
Naiyuan Qing
af79a7fb4e 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>
2026-05-09 14:05:00 +08:00
Naiyuan Qing
a979124c21 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>
2026-05-09 13:38:25 +08:00
Naiyuan Qing
def9c08d35 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>
2026-05-09 13:14:38 +08:00
Naiyuan Qing
518d342021 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>
2026-05-09 09:23:31 +08:00
271 changed files with 37288 additions and 81 deletions

8
.gitignore vendored
View File

@@ -23,6 +23,14 @@ dist-electron
# Desktop production config is public (backend URL, etc.) — track it so
# `pnpm package` produces a release-ready build without extra setup.
!apps/desktop/.env.production
# Mobile staging config is public (staging API URL) — track it so a fresh
# checkout can run `pnpm dev:mobile:staging` / `ios:mobile*:staging` without
# the user having to copy `.env.example` first.
!apps/mobile/.env.staging
# Mobile production config is public (production API URL) — track it so
# external users can run `pnpm ios:mobile:device:prod:release` against
# multica.ai's production backend without copying templates first.
!apps/mobile/.env.production
# test coverage
coverage

View File

@@ -32,11 +32,14 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app (electron-vite)
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
- `apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
- `packages/core/` — Headless business logic (zero react-dom)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
### Key Architectural Decisions
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
@@ -52,7 +55,7 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
@@ -69,6 +72,17 @@ The architecture relies on a strict split between server state and client state.
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
## Sharing Principles
The monorepo splits into two share zones:
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
## Commands
```bash
@@ -111,6 +125,16 @@ cd server && go test ./internal/handler/ -run TestName
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Mobile (Expo) — two environments only: dev and staging
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
@@ -183,17 +207,17 @@ When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this
These are hard constraints. Violating them breaks the cross-platform architecture:
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
### The No-Duplication Rule
### The No-Duplication Rule (web + desktop)
**If the same logic exists in both apps, it must be extracted to a shared package.**
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
@@ -201,9 +225,9 @@ This applies to everything: components, hooks, guards, providers, utility functi
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
### Cross-Platform Development Rules
### Cross-Platform Development Rules (web + desktop)
When adding a new page or feature:
When adding a new page or feature for web/desktop:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
@@ -212,14 +236,18 @@ When adding a new page or feature:
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
### CSS Architecture
### CSS Architecture (web + desktop)
Both apps share the same CSS foundation from `packages/ui/styles/`.
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Mobile-specific Rules
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.

View File

@@ -187,3 +187,5 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
An iOS mobile client lives in [`apps/mobile/`](apps/mobile/) — see its [README](apps/mobile/README.md) for how to build it onto your own iPhone.

View File

@@ -171,6 +171,8 @@ make start
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
iOS 移动端代码位于 [`apps/mobile/`](apps/mobile/),自己编译装到手机的方法见 [README](apps/mobile/README.md)。
## 开源协议
[Apache 2.0](LICENSE)

View File

@@ -39,6 +39,7 @@
"cli",
"auth-tokens",
"desktop-app",
"mobile-app",
"---Developers---",
"developers"
]

View File

@@ -37,6 +37,7 @@
"cli",
"auth-tokens",
"desktop-app",
"mobile-app",
"---开发者---",
"developers"
]

View File

@@ -0,0 +1,82 @@
---
title: Mobile app (iOS)
description: How to build the open-source Multica iOS app on your own iPhone — no App Store yet.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica's iOS client is open-source and lives in the [main repo](https://github.com/multica-ai/multica) alongside web, desktop, and backend. It isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. The build takes about 1020 minutes the first time and ~2 minutes after that, and it talks to the same backend as [multica.ai](https://multica.ai) so your existing account just works.
<Callout type="info">
This page is for **personal use**. App developers should read [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) in the repo — it covers the dev / staging variants and the full script matrix.
</Callout>
## What you need
- A **Mac** with Xcode installed (free from the App Store).
- A free **Apple ID** added under Xcode → Settings → Accounts. A paid Apple Developer Program account is optional and only extends the 7-day signing window to 1 year — see [7-day limit](#7-day-signing-limit) below.
- An **iPhone** connected via USB cable, with [Developer Mode enabled](https://docs.expo.dev/guides/ios-developer-mode/) (Settings → Privacy & Security → Developer Mode).
- The Multica source code checked out:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
pnpm install
```
If anything in that list is missing, walk through Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) (pick **Development build → iOS Device**) — it's the canonical setup guide for everything except the repo checkout.
## Build it
One command:
```bash
pnpm ios:mobile:device:prod:release
```
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — this team is created silently the first time you sign into Xcode with any Apple ID, so it's there even if you don't remember setting anything up. This is a **Release build**: no Metro dependency, splash → app, exactly like an App Store install.
The first build downloads CocoaPods + compiles React Native from source — expect 1020 minutes. Subsequent builds reuse Xcode's cache.
That's it for the typical path. If signing fails, jump to [Troubleshooting](#troubleshooting).
## 7-day signing limit
A free Apple ID signs builds for **7 days**. After that, the app refuses to launch on your iPhone and shows an "untrusted developer" error. Plug back into your Mac and re-run the same command to re-sign — your data stays put because it lives on the backend, not in the app.
The only way to extend this is an **Apple Developer Program account** ($99/yr from [developer.apple.com](https://developer.apple.com)). Signing is then valid for 1 year between renewals, and you can also distribute to other devices via TestFlight.
## Updating
There is no auto-update yet. When the Multica codebase moves forward, pull and rebuild:
```bash
git pull
pnpm install
pnpm ios:mobile:device:prod:release
```
Subsequent builds are fast because Xcode caches the native compile.
## Why no App Store yet
The iOS app is still moving fast — the team prefers ship-and-iterate over App Store review cycles right now. A TestFlight beta is the most likely next step before a full App Store release. Until then, the self-build path above is the only way to use Multica on iOS.
If you'd like to be notified when TestFlight opens, watch the [GitHub repo](https://github.com/multica-ai/multica).
## Troubleshooting
**"No matching provisioning profiles found"** — Xcode refuses to sign the default bundle id `ai.multica.mobile` with your Apple ID. Rare, but happens if someone has registered that prefix on Apple's developer portal. Pick any reverse-domain you control (`com.yourname.multica` is fine), export it, and re-run:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
The id doesn't have to mean anything — Apple just needs it to be unclaimed by other teams.
**"Could not launch &lt;app&gt;" / "Untrusted Developer"** — either you've hit the 7-day limit (re-run the build) or you need to manually trust the developer profile on your iPhone: Settings → General → VPN & Device Management → tap your Apple ID → Trust.
**Build hangs on `Pod install` or compiles forever** — first build is genuinely 1020 minutes because CocoaPods downloads dependencies and Xcode compiles React Native from source. Subsequent builds are much faster.
**App can't reach the backend** — confirm `apps/mobile/.env.production` hasn't been modified (it ships with `EXPO_PUBLIC_API_URL=https://api.multica.ai`). If you changed it, restore with `git checkout apps/mobile/.env.production`.

View File

@@ -0,0 +1,82 @@
---
title: 移动 AppiOS
description: 在自己的 iPhone 上自助 build 开源版 Multica iOS app —— 暂未上 App Store。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica iOS 客户端开源,跟 web、desktop、后端一起放在[主仓库](https://github.com/multica-ai/multica)里。目前没上 App Store —— 在那之前,想用的人自己从源码 build 一份。首次 build 约 1020 分钟,之后每次约 2 分钟,连接的是 [multica.ai](https://multica.ai) 同一个后端,所以你现有账号直接能登。
<Callout type="info">
本页是给**个人使用者**看的。如果你是要开发这个 app,请看仓库里的 [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) —— 那里覆盖 dev / staging 变体和完整脚本表。
</Callout>
## 你需要
- 一台装了 Xcode 的 **Mac**(Xcode 在 App Store 免费下载)。
- 一个免费的 **Apple ID**,在 Xcode → Settings → Accounts 里加进去。付费的 Apple Developer Program 账号是可选的 —— 只把 7 天签名期延到 1 年,见下方[7 天签名限制](#7-天签名限制)。
- 一台通过 USB 线连接的 **iPhone**,并打开 [Developer Mode](https://docs.expo.dev/guides/ios-developer-mode/)(设置 → 隐私与安全性 → 开发者模式)。
- Multica 源码已 clone:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
pnpm install
```
上面任何一项缺失,先走 Expo 的 [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/)(选 **Development build → iOS Device**)—— 它是除仓库拉取外所有环境准备的官方指引。
## Build
一条命令:
```bash
pnpm ios:mobile:device:prod:release
```
Xcode 会用你 Apple ID 自动持有的"Personal Team"来签名 —— 这个 team 是你第一次用任何 Apple ID 登 Xcode 时静默建的,所以即使你不记得"什么时候弄过",它都已经在那里了。这是个 **Release build**:不依赖 Metro,启动屏 → app,跟从 App Store 装的体验一样。
首次 build 会下载 CocoaPods + 从源码编译 React Native —— 大约 1020 分钟。之后 build 会快很多,Xcode 缓存了原生编译产物。
典型路径就这样。签名失败的话见下方[排错](#排错)。
## 7 天签名限制
免费 Apple ID 签的 build 只有 **7 天**有效期。过期后 app 在 iPhone 上拒绝启动,提示 "untrusted developer"。插回 Mac 重跑同一条命令重签即可 —— 数据不会丢,因为数据在后端,不在 app 里。
唯一的延期方式是 **Apple Developer Program 账号**($99/年,在 [developer.apple.com](https://developer.apple.com) 注册)。有了它签名一次有效 1 年(直到续费),还能通过 TestFlight 分发给其他设备。
## 更新
暂时没有自动更新。Multica 代码库前进时,你 pull 然后重 build:
```bash
git pull
pnpm install
pnpm ios:mobile:device:prod:release
```
后续 build 很快,因为 Xcode 缓存了原生编译产物。
## 为什么还没上 App Store
iOS app 还在快速迭代 —— 团队目前更倾向于"先发再改",而不是 App Store 审核周期。下一步比较可能是 TestFlight 内测,然后才是正式上架。在那之前,上面的自助 build 是 iOS 上用 Multica 的唯一方式。
想第一时间知道 TestFlight 开放的话,watch 一下 [GitHub 仓库](https://github.com/multica-ai/multica)。
## 排错
**"No matching provisioning profiles found"** —— Xcode 拒绝用你的 Apple ID 签默认的 `ai.multica.mobile`。比较罕见,如果有人在 Apple Developer Portal 抢注了这个前缀就会出现。换一个你控制的反向域名(`com.yourname.multica` 就够),export 后重跑:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
id 本身没意义,Apple 只要求它没被别的 team 抢注就行。
**"无法启动 &lt;app&gt;" / "未受信任的开发者"** —— 要么过了 7 天有效期(重跑 build),要么需要在 iPhone 上手动信任开发者证书:设置 → 通用 → VPN 与设备管理 → 点你的 Apple ID → 信任。
**Build 卡在 `Pod install` 或者编译很久不动** —— 首次 build 就是 1020 分钟,CocoaPods 要下载依赖、Xcode 要从源码编译 React Native。后续会快很多。
**App 连不上后端** —— 确认 `apps/mobile/.env.production` 没动过(默认值 `EXPO_PUBLIC_API_URL=https://api.multica.ai`)。如果你改过,用 `git checkout apps/mobile/.env.production` 还原。

34
apps/mobile/.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Mobile env template — copy this to one of:
# .env.development.local (used by `*:mobile` — local backend)
# .env.staging (used by `*:mobile:staging` — remote staging)
#
# All five mobile scripts read one of these two files, depending on suffix:
# dev:mobile / dev:mobile:staging — Metro only
# ios:mobile:device / ios:mobile:device:staging — Debug build to iPhone
# ios:mobile:device:staging:release — Release build to iPhone
#
# How EXPO_PUBLIC_* values reach the installed app:
# - Metro reads this file once at startup and inlines the values into every
# JS bundle it serves. Editing the file mid-session does NOT auto-refresh
# — restart Metro (Ctrl+C, then re-run `dev:mobile*`) to pick up changes.
# - For an installed Release build the value is baked into the embedded
# bundle at `ios:*:release` time; the only way to change it is to re-run
# the release build.
#
# Phone must be able to reach this URL. For local dev use your Mac's LAN IP
# (run `ipconfig getifaddr en0`), not `localhost` / `127.0.0.1`.
#
# Staging URL: see apps/desktop/.env.staging (`VITE_API_URL`) for the canonical
# value, or ask a teammate. Same backend across mobile / web / desktop.
EXPO_PUBLIC_API_URL=https://<api-host>
# Optional. Overrides the iOS bundleIdentifier for the DEV variant only so a
# dev whose Apple ID isn't on the Multica Apple Developer team yet can still
# sign local builds. Use a reverse-domain you own (e.g. com.<yourname>.multica).
# Leave unset to use the default ai.multica.mobile.dev.
#
# Only read in `.env.development.local` — staging / production bundle ids are
# never overridable (variants must stay on their canonical ids so the same
# device can hold all three side-by-side).
# EXPO_BUNDLE_IDENTIFIER_DEV=com.yourname.multica.dev

View File

@@ -0,0 +1,5 @@
# Mobile production env — committed so external users can build a personal
# iPhone copy of Multica against the same backend as multica.ai on web.
# Loaded by the `*:prod` scripts via dotenv-cli (see package.json).
EXPO_PUBLIC_API_URL=https://api.multica.ai
EXPO_PUBLIC_WEB_URL=https://multica.ai

10
apps/mobile/.env.staging Normal file
View File

@@ -0,0 +1,10 @@
# Used by `pnpm dev:mobile:staging` and the `ios:device:staging[:release]`
# scripts. Loaded via `dotenv-cli` (see package.json), NOT by Expo's auto-
# loader — Expo only auto-loads .env.<NODE_ENV>.local files.
EXPO_PUBLIC_API_URL=https://multica-api.copilothub.ai
# Optional. Enables "Copy link" / "Open on web" actions in issue / project /
# comment menus. Without it those menu items just don't appear. Fill in the
# staging web host when you have it (canonical value lives in
# apps/desktop/.env.staging on a teammate's machine).
# EXPO_PUBLIC_WEB_URL=https://<staging-web-host>

28
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
node_modules/
.expo/
dist/
web-build/
# macOS
.DS_Store
# Local env files only. `.env.staging` is committed — the override that
# rescues it from the repo-root `.env*` ignore rule lives in the root
# .gitignore (`!apps/mobile/.env.staging`).
.env*.local
# Native (Expo prebuild output)
ios/
android/
# Override the root .gitignore "data/" rule (intended for backend runtime
# dirs). apps/mobile/data/ is source — TanStack Query queries, mutations,
# stores, ApiClient — and MUST be tracked.
!data/
!data/**
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

575
apps/mobile/CLAUDE.md Normal file
View File

@@ -0,0 +1,575 @@
# Mobile App Rules (apps/mobile/)
For cross-app sharing rules, see the root `CLAUDE.md` *Sharing Principles* section. This file documents the locked tech-stack baseline and the few mobile-specific rules — so AI doesn't suggest outdated alternatives.
## What mobile may import from `packages/`
- `import type` from `@multica/core/types/*` (zero runtime coupling)
- Pure functions from `@multica/core/`
Everything else, mobile writes its own.
## Pre-flight — before you write any code
For any new mobile feature / screen / interaction, complete the three steps below in order. **Skipping any step = no code yet** (read-only investigation and answering questions are exempt). This section overrides every other rule in this file.
### 1. Read the real web/desktop implementation
Until you can name the relevant code, don't reason from "general experience":
- `packages/views/<feature>/` — UI shape, information density
- `packages/core/<feature>/{queries,mutations,ws-updaters}.ts` — endpoints, cache key shapes, optimistic patches, WS event coverage
- Anything matching `*-display.ts` / `dedupe*` / `coalesce*` / `useMemo(() => transform(raw))` — preprocessing between backend and JSX
List the **must-agree points**: counts, enums, permissions, cross-cache side effects (e.g. a status change must also refresh inbox), navigation flow. Missing one of these is how the 2026-05-09 inbox duplicate-dot incident happened.
### 2. Show the user the interaction plan + parity points (≤30s to read)
Include:
- What you're about to build (one sentence)
- The container / interaction you propose (after walking the iOS-native > RNR > ask waterfall in §UI components)
- Mental-model parity points pulled from step 1 (example: "counts mirror `deduplicateInboxItems`")
- What UI **must differ** and why (example: "web has a sidebar workspace switcher; mobile puts it in Settings — same switching semantics")
- **Visual baseline check** (this is baseline, not polish): tab bar has icons, every screen has a title, multiple right-side row elements stack vertically, secondary text routes through a type-aware label; place a web screenshot next to a simulator screenshot
### 3. Wait for an explicit "do it / go / start" before writing code
"Yes / right / sounds good" ≠ permission to act. "How should we do X?" ≠ permission to act. Only an explicit imperative ("build X / change X / start") triggers code.
> Detailed rules live downstream: must-agree details in §Behavioral parity; component waterfall in §UI components; data / mirroring rules in §Data layer helpers and §Realtime. Pre-flight is the gate; those are the references.
## Behavioral parity with web/desktop
Mobile is allowed to differ in **UI and interaction** — it's a phone, not a port. It is NOT allowed to differ in **product semantics**. Users should not get a different mental model of "what's there" depending on which client they open.
**The four things that must agree:**
- **Counts / visibility** — same N for the same filter, under identical pagination / coalescing rules.
- **Permissions / access** — mirror the same logic web uses (from `packages/core`); don't re-derive from feel.
- **State enums / transitions** — render every status / priority / inbox type / comment type, with a sensible fallback for unknown values (per "API Response Compatibility" in the root CLAUDE.md). Never silently drop a category.
- **Data identity** — same `id`, same `slug`, same canonical fields. Don't invent ids or normalize differently.
**When UI must diverge**, write at the divergence point what rule it's mirroring (point at the source function in `packages/core` or `packages/views`) and why mobile renders it differently. A future reader should be able to tell in 30 seconds that the divergence is intentional and find the web-side source of truth.
### ⚠️ Incident (2026-05-09): inbox dedup missing — counts disagreed
**Symptom**: Web sidebar showed "Inbox 1" while mobile rendered 3+ unread dots on the same workspace, same user, same moment.
**Root cause**: Backend `GET /api/inbox` returns raw rows that include:
1. archived items, and
2. multiple inbox notifications per issue (a comment, a status change, and an assignment on the same issue each create one row).
Web/desktop run those raw rows through `deduplicateInboxItems` (`packages/core/inbox/queries.ts`) before rendering and before counting unread:
1. filter `archived = true` out
2. group by `issue_id`, keep the newest in each group
3. sort by `created_at` desc
Mobile's first cut rendered the raw list directly. So a single issue with 3 notifications showed as 3 rows with 3 unread dots, while web showed 1.
**Fix**: mirror `deduplicateInboxItems` into `apps/mobile/lib/inbox-display.ts`, run mobile's inbox tab through it before rendering and before any counting.
**Lesson — encode this into your reflexes when adding any new mobile screen that consumes a list endpoint**:
> Before rendering an API list response, grep `packages/core/<domain>/queries.ts` and `packages/views/<domain>/components/*.tsx` for any preprocessing — `dedupe*`, `coalesce*`, `filter*`, `*-display.ts`, `useMemo(() => transform(raw))`. Mirror everything that runs between `useQuery` and the JSX in web/desktop. **Do not assume the backend returns "what should be displayed"** — it usually returns the raw cache shape, and the client is responsible for shaping it.
This pattern repeats: timeline coalescing (`buildTimelineGroups`), inbox dedup, comment thread flattening, etc. Each one is a behavioral parity hazard if mobile skips it.
## Tech-stack baseline
Start minimal. Add to this list when actually adopted — do NOT pre-list libraries.
- **Expo SDK 55**
- **React Native 0.82**
- **React 19.1** — whatever Expo SDK 55 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`.
- **TypeScript** strict
- **Expo Router 55** (file-based routing — version aligns with Expo SDK)
- **NativeWind 4** + **Tailwind 3.4** — NativeWind 5 is unstable; stay on v4. (Note: web/desktop use Tailwind v4 — versions intentionally differ.)
- **react-native-reusables (RNR)** — the shadcn equivalent for React Native. Uses NativeWind + RN-Primitives + CVA. Component API mirrors shadcn. **Phased adoption in progress — see `apps/mobile/docs/rnr-migration.md` for the canonical plan, three-tier classification, and Phase 0/1/2/3 status.**
- **TanStack Query 5** — mobile owns its `QueryClient` with `AppState` focus listener + `NetInfo` online listener.
- **Zustand** — mobile-local state only.
- **expo-secure-store** — auth token persistence + theme preference (`light` / `dark` / `system`).
When upgrading any of these, update this list.
## UI components & theming
The full plan, file inventory, and migration phases live in `apps/mobile/docs/rnr-migration.md`. The rules below are the durable ones that must survive after the migration completes — read this section first when working on any UI.
### Hard rule — existing pattern first, defaults first, native waterfall
Three principles govern every UI decision on mobile. They exist to fight the temptation to recreate things that already exist — which is exactly the trap that produced the current 21 hand-written components and 18 hand-rolled sheets.
**Principle 1 — existing pattern first.** Before reaching for ANY new component (RNR add, hand-written primitive, new sheet container), grep the mobile codebase for an already-shipped pattern that does the same thing.
- Building a row → grep `components/inbox/`, `components/issue/`, `components/project/` for an analogous list-row first.
- Building a picker / sheet → check `components/issue/pickers/`, `components/project/pickers/` — there are 8+ pickers; one of them is probably the shape you need.
- Building a status / priority / actor visual → `components/ui/status-icon.tsx`, `priority-icon.tsx`, `actor-avatar.tsx` already exist. Re-use, don't re-skin.
- Composer / form / detail screen layout → `app/(app)/[workspace]/issue/[id]/`, `chat/`, `new-issue.tsx` — copy the structure, don't reinvent.
If a working pattern exists, **import or copy-adapt it**. If it almost-fits but needs a small extension, extend the existing one (one PR) rather than fork a second variant. Only when no existing pattern fits, proceed to Principle 2.
Why: every "I'll just write a fresh one" produced one of the 21 legacy components. The codebase already paid the cost of figuring out the iOS-correct shape for inbox rows, picker sheets, status icons — don't re-pay it.
**Principle 2 — defaults first.** When you use any RNR component, accept its default variant, default size, default spacing, default palette. Do NOT add wrapper layers, "improved" defaults, or `variant="multicaCustom"` styles unless a concrete product need demands it. Reaching for shadcn defaults is correct; reaching for a hand-tuned version of them is the failure mode.
**Principle 3 — iOS native > RNR > discuss.** When you need a new interaction, walk this waterfall in order, stop at the first hit:
1. **iOS / RN ships a native API?** Use it directly. Don't wrap a `Modal` to mimic it.
- Text input prompt → `Alert.prompt`
- Confirm / destructive prompt → `Alert.alert`
- Action sheet (one-of-N) → `ActionSheetIOS.showActionSheetWithOptions`
- Date / time → `@react-native-community/datetimepicker` (already installed)
- Image / camera → `expo-image-picker` (already installed)
- Documents → `expo-document-picker` (already installed)
- Share → `Share.share` from `react-native`
- Haptics → `expo-haptics` (already installed)
2. **RNR ships a matching component?** `npx @react-native-reusables/cli@latest add <name>`. Use the default variant/size/palette.
3. **Neither.** **Stop and ask the user.** Don't silently hand-roll a replacement — that's exactly how the pre-migration legacy accumulated.
### Component placement
After deciding via the waterfall:
- **Generic UI primitives** → `components/ui/`. Either RNR `add` output or hand-written with `cva` + `cn()` + semantic tokens + `@rn-primitives/*` building blocks.
- **Domain UI** (anything mentioning issues, priorities, statuses, actors, agents, presence, projects, runs) → `components/<domain>/`. Composes primitives but isn't generic.
Never copy the visual shape of an existing hand-written `components/ui/` component as a template if its RNR equivalent exists — most of them are pre-migration legacy. The migration doc tracks which files are legacy and which have been replaced.
### Theming model — CSS variables + class-based dark mode
- Source of truth for colors is `global.css` — CSS variables defined under `:root` (light) and `.dark:root` (dark). `tailwind.config.js` maps utilities like `bg-background` to `hsl(var(--background))`, so the same class name resolves to the right color in either mode automatically.
- `darkMode: 'class'` (NOT media-query). We control the mode explicitly so the in-app Settings → Appearance picker (`light` / `dark` / `system`) can override the OS preference.
- The mode is switched by NativeWind's `useColorScheme().setColorScheme(mode)`. Calling it sets the root class; every `bg-foo` / `text-foo` reactively rebinds to the new variable values. No manual className toggling, no re-render dance.
- React Navigation (`expo-router`'s `Stack` headers, modal chrome, drawer) is themed separately by passing `NAV_THEME[isDarkColorScheme ? 'dark' : 'light']` into `ThemeProvider`. Source of `NAV_THEME` is `lib/theme.ts`, which mirrors `global.css` in TypeScript.
- Persistence: the user's choice goes into `expo-secure-store` under the key `theme-preference` (values: `light` / `dark` / `system`). Loaded synchronously at app startup in `app/_layout.tsx` before the first paint; missing key defaults to `system`.
- **When you change a CSS variable in `global.css`, also update `lib/theme.ts`.** They mirror each other. The RNR docs include a prompt template for this sync.
### What this replaces (and what stays)
- The old "Visual tokens" approach — hand-transcribed hex values in `tailwind.config.js` — is being **replaced** by the CSS-variable system above. Web tokens are still inspiration only; we do NOT import `packages/ui/styles/tokens.css` (Tailwind v3.4 vs v4 mismatch makes file sharing impractical; isolation is intentional).
- The `cn()` helper at `lib/utils.ts` stays — RNR uses the same one.
- The sheet rule from Lesson 6 below still applies. RNR ships `Dialog` and other modal primitives; use them for **new** sheets. The legacy `sheet-shell.tsx` (RN `<Modal presentationStyle="pageSheet">`) has been deleted — every long-list / search / form sheet now uses an Expo Router `presentation: "formSheet"` route, which instantiates iOS' `UISheetPresentationController` for native grabber, detents, and spring drag physics.
## Build & release
- **Main CI** (`.github/workflows/ci.yml`) excludes mobile via `--filter='!@multica/mobile'`. Mobile failures do NOT block web/desktop PRs.
- **Mobile verify** (`.github/workflows/mobile-verify.yml`): triggered on `apps/mobile/**` or `packages/core/types/**` changes — runs typecheck/lint/test only, no IPA build.
- **Mobile release** (`.github/workflows/mobile-release.yml`): triggered by `mobile-v*.*.*` tag → `eas build` + `eas submit`.
- **OTA** — EAS Update for JS-only fixes that don't change the runtime version. Manual / on-demand push to preview/production channels.
Mobile release cadence is decoupled from main `v*.*.*` tags (server / CLI / desktop).
## Realtime / WebSocket strategy
Mobile uses the same WS server protocol as web/desktop, but mounts subscriptions differently. The rules below exist because mobile-specific constraints (cellular data cost, AppState lifecycle, per-screen unmount cleanup, smaller cache surface) make a direct port of web's pattern wrong.
### Three-layer stack
```
Layer 1 ws-client.ts — single socket, no React. Exponential
backoff with full jitter. Three-state
lifecycle (idle / active / paused) so
the provider can pause on background
and resume on foreground without
racing the auto-reconnect timer.
Layer 2 realtime-provider.tsx — owns the WSClient. Mounts/unmounts on
auth + workspace + AppState + NetInfo
changes. Exposes useWSClient().
Layer 3 use-<feature>-realtime.ts — per-feature subscriptions. Translate
events → cache mutations.
```
Layer 3 is what changes per feature; layers 1 and 2 are infrastructure and shouldn't be edited when adding event coverage.
### Mount strategy: list-level global, per-record per-screen
Mobile **does NOT use a single centralized `useRealtimeSync` hook** like `packages/core/realtime/use-realtime-sync.ts`. That pattern is fine on web (one tab = one mount, lives forever) but on mobile it gets in the way: most events care about a single record (one issue's comments, one chat session's messages), and the hook needs to know which record without prop-drilling.
Two mount tiers:
- **Listing-level (always-on for the workspace session)** — mount inside the `<RealtimeSubscriptions />` component in `app/(app)/[workspace]/_layout.tsx`. These don't take parameters; they patch caches keyed only on `wsId`. Examples: `useInboxRealtime`, `useMyIssuesRealtime`. Both run from the moment the user enters a workspace until they leave it, regardless of which tab is foregrounded.
- **Per-record (mounted with id, cleans up on unmount)** — mount inside the screen that owns the record, parameterized by the id from the route. Example: `useIssueRealtime(id, () => router.back())` in `issue/[id].tsx`. The hook filters every event by `payload.issue_id === id` and only patches the current issue's caches. When the user navigates away the `useEffect` cleanup unsubscribes all listeners, so a backgrounded screen doesn't keep mutating caches it no longer owns.
Don't mount a per-record hook globally to "just be safe" — every filter call on every event then runs N times where N is the number of issues a user has ever opened in this session.
### Patch over invalidate (cellular-data rule)
When a WS payload contains the full updated object, **patch** the cache (`setQueryData` / `setQueriesData`). Only fall back to **invalidate** when:
1. The payload is just an id (we don't know the full new shape — e.g., `issue:created` with no scope context).
2. The cache shape doesn't match what we can patch (e.g., multi-key scope-filtered lists where we'd have to predict membership).
3. The event is rare enough that the extra refetch isn't a real cost (e.g., `issue:deleted` on a list that was about to invalidate anyway).
4. After a reconnect, where we may have missed events while disconnected.
Web is fine to invalidate generously because most users are on broadband; mobile users on cellular pay for each refetch. A `setQueryData` is free; an `invalidateQueries` is a network roundtrip per affected query key.
### Mobile-owned updaters (don't import `packages/core/issues/ws-updaters.ts`)
Mobile has its own `apps/mobile/data/realtime/issue-ws-updaters.ts` even though web has a near-identical file. **Do not import web's updaters into mobile.** Two reasons:
1. **Key-factory binding.** Web's updaters reference `issueKeys` from `packages/core/issues/queries.ts` — a different runtime instance from mobile's `apps/mobile/data/queries/issue-keys.ts`. TanStack Query compares keys structurally so it *appears* to work, but binding cache mutation to a foreign key factory invites silent drift the moment either side adjusts its key shape (renames a segment, adds a discriminator).
2. **Cache-shape divergence.** Mobile has simpler caches: flat `Issue[]` for my-issues (web has status-bucketed); no children subtree (web does); no label-byIssue cache (web does). Web's updaters carry conditional dead-code for paths mobile doesn't have, and mobile would silently no-op on web shapes that don't exist locally.
When the same logic needs to exist on both sides, copy the design — not the import. Document the mirror at the top of the mobile file (see `issue-ws-updaters.ts` for the pattern).
### Event-always-wins (optimistic conflict policy)
Mutations like `useUpdateIssue` apply an optimistic patch to the detail cache, then the server processes the request and broadcasts `issue:updated`. If a separate WS event (from another client / another user / an agent) arrives between the optimistic patch and the mutation response, the WS handler overwrites the optimistic state with the server's authoritative state. Brief UI flicker is acceptable; correctness wins.
**Do not** add timestamp-comparison logic to "protect" the optimistic state — the server is the truth and the user benefits from seeing real changes immediately. If a specific event proves problematic in practice, add the gate at that point, not by default.
### Reconnect handling
Each hook registers a single `ws.onReconnect(cb)` that invalidates **only the queries it owns**:
| Hook | Invalidates on reconnect |
|---|---|
| `useInboxRealtime` | `inboxKeys.list(wsId)` |
| `useMyIssuesRealtime` | `issueKeys.myAll(wsId)` |
| `useIssueRealtime(id)` | `issueKeys.detail(wsId, id)` + `issueKeys.timeline(wsId, id)` |
No global "invalidate everything on reconnect" sweep. The fanout would be every screen the user has ever visited in this session refetching simultaneously — wasteful on cellular and prone to rate-limiting the server in low-signal areas where reconnects happen frequently.
### Cross-cutting cache patches across features
Some events legitimately need to mutate a foreign feature's cache. The
canonical example: `issue:updated` changing an issue's status must also
update the StatusIcon shown on the matching inbox row, and `issue:deleted`
must strip every inbox row pointing at the dead issue.
The pattern:
1. **The feature whose cache is being patched owns the updater.** Example:
`apps/mobile/data/realtime/inbox-ws-updaters.ts` exports
`patchInboxIssueStatus` and `dropInboxItemsByIssue` — they live with
inbox, not with issues, because they read `inboxKeys.list(wsId)`.
2. **That feature's realtime hook subscribes to the foreign event.**
`use-inbox-realtime.ts` subscribes to `issue:updated` and `issue:deleted`
alongside the `inbox:*` events. The issue-realtime hook does NOT know
that inbox cares.
3. **Mirror web's wiring.** Web's `packages/core/inbox/ws-updaters.ts` has
the same handlers; mobile copies the design. Behavioral parity hazard:
without these the mobile inbox row keeps showing the prior status (or
404s on tap if the issue is gone) while web users see the change live.
If you find yourself reaching across features in `use-issues-realtime` to
patch something else, you have the inversion: move the updater to the
patched feature and subscribe there.
### Adding new event coverage — recipe
1. **Read the payload.** Find the event in `@multica/core/types/events.ts`. Note the fields; decide if patch is possible (full object) or invalidate is required (just an id).
2. **Mirror, don't import.** If web has an updater for this event in `packages/core/<feature>/ws-updaters.ts`, copy the design into `apps/mobile/data/realtime/<feature>-ws-updaters.ts`. Adapt to mobile's actual cache shapes — don't carry web's bucket/children/childProgress dead-code if mobile doesn't have those caches.
3. **Subscribe in a hook.** Either extend an existing `use-<feature>-realtime.ts` or create a new one. Filter by id at the top of each handler so per-record hooks ignore unrelated events.
4. **Mount it.** Listing-level → add to `<RealtimeSubscriptions />` in workspace `_layout.tsx`. Per-record → add to the owning screen's body, parameterized by the route id.
5. **Add reconnect invalidate.** Single `ws.onReconnect()` call scoped to the hook's own keys.
6. **Verify cross-client.** Open the affected screen on mobile, change the same record from a second client (web or another device), confirm mobile updates within ~500ms without pull-to-refresh.
If a new event has no consumer on mobile (e.g., `subscriber:added` when mobile doesn't render subscriber lists yet), **don't subscribe**. Mounting a listener with no UI consumer adds CPU on every fire for zero user benefit.
## Data layer helpers (use these — don't recreate them)
Common boilerplate is wrapped. New code that reinvents these helpers is a
review-block, both because it makes the codebase inconsistent AND because
the helpers encode subtle correctness rules (signal forwarding, schema
fallback, sync-before-await ordering, type-safe payloads).
### Three rails that every feature must follow
1. **Logic mirrors web/desktop.** See §Pre-flight step 1 at the top of
this file. Restating the data-contract half here: endpoints, request
bodies, response schemas, optimistic patches, and cache key prefixes
all match web verbatim. UI / interaction can diverge freely per
§Behavioral parity.
2. **Use the existing components — no new primitives.** Walk the
`iOS native > RNR > discuss` waterfall in §UI components. If RNR ships
it, `npx @react-native-reusables/cli@latest add <name>`. If iOS ships
it (Alert / ActionSheetIOS / Haptics / share / picker), use it directly.
If neither has it AND it's a single-screen need, inline compose with
`<Pressable>` + `<Text>` + tokens. **Do NOT create a new generic
primitive in `components/ui/` for one or two callers** — the migration
doc lists "21 hand-written components" as exactly the trap we're
escaping. Threshold for a new primitive is three callers AND no
RNR/iOS-native alternative.
3. **Use the wrapped request / WS layer.** See the helper map below.
### API client: `fetchValidated` + `fetchValidatedWith`
`apps/mobile/data/api.ts` exposes two private helpers on `ApiClient` that
collapse the fetch + parseWithFallback envelope. **Every new read-side
method that returns a typed body must use them.**
| Helper | When to use | Shape |
|---|---|---|
| `this.fetchValidated(path, schema, fallback, opts?)` | GET endpoints | One-liner method body — see `getMe`, `listInbox`, `getNotificationPreferences` |
| `this.fetchValidatedWith(path, schema, fallback, init, opts?)` | Any HTTP method (PATCH / PUT / POST) whose response is consumed | Carries the body via `init.body` + method; signal forwarding handled |
| `this.fetch<T>(path, init?)` directly | Writes whose response is `{ count }` / `void` / not consumed by UI logic | Only here is a raw `as T` acceptable, because the value never reaches a render path |
Rules:
- The fallback object MUST match the success type exactly so downstream
code never has a partial value (see `EMPTY_USER` / `EMPTY_INBOX_LIST`
pattern in `apps/mobile/data/schemas.ts`).
- The `endpoint` label is for telemetry — defaults to the path; override
only when the path has dynamic segments and you want stable groupings
(`GET /api/issues/:id` not `GET /api/issues/abc-123`).
- Migration is progressive: not every legacy method is converted yet.
Adding a new method? Use the helpers. Touching an old method that
isn't using them? Convert it as part of the same PR.
### Query / mutation factory pattern
Every workspace-scoped feature exposes a key factory in
`apps/mobile/data/queries/<feature>.ts`:
```ts
export const inboxKeys = {
all: (wsId: string | null) => ["inbox", wsId] as const,
list: (wsId: string | null) => [...inboxKeys.all(wsId), "list"] as const,
};
```
Three-segment shape matches web (`packages/core/inbox/queries.ts`).
Reasons:
- TQ does prefix matching by default — `invalidateQueries({ queryKey:
inboxKeys.all(wsId) })` invalidates the list AND any future sub-keys
(e.g. a `detail(id)`) under the same prefix. Use `.all` to clear a
workspace cleanly, `.list` to target the list specifically.
- Cross-platform mental-model parity: a reader switching between mobile
and web finds the same key shape.
- Stops bare `["inbox", wsId]` strings from spreading. Grep
`\["inbox"` in this codebase should only hit the factory file.
Mutations import the factory and use `inboxKeys.list(wsId)` everywhere —
never inline strings.
### WS layer: `ws.on<E>()` + `useWSSubscriptions`
Two helpers replace ~20 lines of boilerplate per realtime hook:
1. **`ws.on<E extends WSEventType>(event, handler)`** — the handler's
`payload` parameter is auto-typed to `WSEventPayload<E>`. **Do not
add `as XxxPayload` casts at handler bodies** — they're redundant
and (worse) silently hide drift if `WSEventPayloadMap` shifts.
The cast is only acceptable when one handler covers multiple events
that don't share a typed common ancestor (see `onTaskEvent` in
`use-issue-realtime.ts` — `task:progress` has no formal payload).
2. **`useWSSubscriptions(setup, deps)`** in
`apps/mobile/lib/use-ws-subscriptions.ts` — wraps the
`if (!ws || !wsId) return; useEffect + cleanup` template. Setup
callback receives `(ws, wsId)`, returns the unsub array (or
`undefined` to short-circuit, e.g. when a per-record id is missing).
Adding a new event type? Extend `packages/core/types/events.ts`:
1. Add the event to the `WSEventType` union.
2. Add the payload interface.
3. Add the `WSEventType → payload` entry in `WSEventPayloadMap`.
Forgetting step 3 means callers get `unknown` (loud — they have to
narrow), not `any` (silent unsafe access). That's the safety net.
### Synchronous setQueryData before `await cancelQueries`
Optimistic mutations that flip state read by a UI element that's about
to be in a navigation snapshot (the classic case: marking an inbox row
read, then `router.push` to the issue) MUST call `setQueryData` in
`onMutate` **before** `await qc.cancelQueries(...)`. The await yields
one microtask; iOS captures the source-view snapshot during that gap and
freezes the row in its unread style inside the slide-in transition.
Lives inside the mutation, not the caller. See `useMarkInboxRead.onMutate`
in `apps/mobile/data/mutations/inbox.ts` for the canonical example.
### Checklist for a new feature
Before opening a PR for a new screen / mutation / realtime hook:
1. Grep `packages/core/<feature>/` for the web equivalent — endpoints,
key shape, optimistic patch shape. Mirror, don't invent.
2. API methods → `fetchValidated` / `fetchValidatedWith` (or raw
`this.fetch` only for writes with no consumed response).
3. Query key → factory in `data/queries/<feature>.ts`, 3-segment shape.
4. Mutations → optimistic three-step (snapshot → patch → rollback) +
settle invalidate, all keys via factory.
5. Realtime → `useWSSubscriptions(setup, deps)`, typed `ws.on<E>()`,
per-event patching (no global invalidate) when payload carries the
full object.
6. UI → waterfall (iOS native > RNR > inline compose). No new
`components/ui/` primitive unless three callers + RNR doesn't ship.
7. Verify cross-client: change the same record from web and confirm
mobile updates within ~500ms without pull-to-refresh.
## Lessons learned (encode into reflexes)
These are real mistakes that have been made building the mobile shell. Each one cost time to find. Treat as enforceable rules, not suggestions.
### 1. Install/upgrade any dependency: check `dist-tags` first
Do NOT hardcode version numbers from memory. Run `pnpm view <pkg> dist-tags` to see `latest / sdk-XX / canary` and decide which tag to lock. For Expo packages (`expo-*` / `react-native-*` that Expo aligns), use `pnpm exec expo install <pkg>` — it queries Expo's dependency manifest and picks the SDK-compatible version. `pnpm add <pkg>` will silently install the npm `latest`, which often outpaces the SDK and breaks at runtime. Past mistakes: hardcoded `expo@~54.0.0` (latest was already `55.x`); installed `lucide-react-native@0.468` without checking React 19 peer compatibility.
### 2. New source subdirectory: verify git tracking
Every time you create a new source subdirectory under `apps/mobile/` (e.g. `data/`, `lib/foo/`, `components/inbox/`):
1. Run `git check-ignore -v <dir>/<file>` immediately. The repo-root `.gitignore` has generic rules (`data/`, `build/`, `bin/`, `*.app`, `*.dmg`) that are intended for backend runtime/output dirs but will silently swallow mobile source.
2. If a rule matches, add `!<dir>/` and `!<dir>/**` to `apps/mobile/.gitignore` (subtree override beats parent rule).
3. After the commit lands, run `git ls-files <dir>` to confirm every file is tracked.
This rule exists because `apps/mobile/data/` was once committed-but-not-tracked — 14 source files (ApiClient, all queries, all stores) were missing from the git tree even though `git status` was clean. Local builds worked because Metro reads the filesystem; CI / clones would have died.
### 3. ApiClient capability list (4 must-haves)
Mobile's fetch wrapper (`apps/mobile/data/api.ts`) MUST implement all four. Missing any of them is a bug, not a deferred polish item.
1. **Zod `parseWithFallback` for response validation.** Strictly enforced by the root CLAUDE.md "API Response Compatibility" section and the "Type drift defense" section above. **Any new endpoint method that does `as T` on the response body is a bug.** Reuse schemas from `packages/core/api/schemas.ts` (pure Zod exports, on the mobile sharing whitelist); define mobile-side fallbacks for new endpoints in `apps/mobile/data/`.
2. **`onUnauthorized` 401 callback.** The `ApiClientOptions.onUnauthorized` hook fires on every 401 and must be wired in `app/_layout.tsx` to: clear auth token, clear workspace store, clear TanStack Query cache, navigate to `/login`. Without it a session that expired server-side puts every subsequent request into a 401 loop and the user sees opaque "API error: 401" toasts on every screen. Use a `signingOutRef` to make the callback idempotent — multiple in-flight requests will all 401 simultaneously when a session expires.
3. **`X-Request-ID` per request.** Generate a short random ID (`createRequestId()` in `apps/mobile/lib/request-id.ts`), send as `X-Request-ID` header. The same ID goes into client-side log lines so backend telemetry can be cross-referenced (server picks it up via the same header).
4. **Structured request logger.** Two log lines per request: `[api] → METHOD path` (start, with `rid`) and `[api] ← STATUS path` (end, with `rid` + `duration`). Use `console.error` for 5xx, `console.warn` for 404s, `console.log` for success. Without this, debugging mobile API issues means staring at the React Native Network panel; with it, the dev console is self-explanatory and prod telemetry already comes structured.
**What mobile correctly does NOT need (don't add these):** CSRF token (`X-CSRF-Token`), `credentials: "include"`, cookie reading. Mobile is Bearer-token auth, not cookie auth — the cookie attack surface that requires CSRF protection on web doesn't exist on mobile.
### 4. Every read query must pass `signal` to fetch; api.ts always has a hard timeout
**Symptom that triggered the rule (2026-05-11)**: Inbox screen sometimes returned to the foreground showing the FlatList pull-to-refresh spinner stuck indefinitely. List items were rendered underneath, but `isRefetching` never flipped back to `false`. Pull-to-refresh, navigating away, and re-opening the tab did not clear it.
**Root cause**: `apps/mobile/data/api.ts`'s `fetch()` had no timeout, no `AbortController`, and no caller-`signal` plumbing. iOS suspends backgrounded apps within ~30 seconds and can silently kill in-flight network tasks (facebook/react-native#35384 — "iOS fetch() POST fails if called too soon, with app running in background"; facebook/react-native#38711 — "JS Timers don't fire when app is launched in background"). When the app foregrounded, the suspended fetch's Promise neither resolved nor rejected. TanStack Query saw an existing query still in `fetching` state and did NOT start a new fetch on invalidate — it just waited on the dead Promise forever. `isRefetching` stayed `true`, the FlatList spinner stayed spinning.
**Rule, three parts (every one is required — partial fixes leave a footgun)**:
**1. `api.ts` `fetch()` MUST have a hard timeout** (currently 30s; the `FETCH_TIMEOUT_MS` constant). Without this, a single suspended request can wedge a query indefinitely. Use a manual `AbortController` + `setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)` — **DO NOT** use `AbortSignal.timeout()`: Hermes throws `TypeError: AbortSignal.timeout is not a function` (facebook/react-native#42042). Same for `AbortSignal.any()` — Hermes does not implement it (livekit/livekit#4014). To combine the timeout signal with a caller-supplied signal, attach an `"abort"` event listener manually and forward to the inner controller.
**2. Every read-side `api.ts` method MUST accept `opts?: { signal?: AbortSignal }` and pass it to `fetch()`**. Mutations don't need this (TanStack Query doesn't pass a signal to `mutationFn`). The pattern:
```ts
async listInbox(opts?: { signal?: AbortSignal }): Promise<InboxItem[]> {
return this.fetch<InboxItem[]>("/api/inbox", { signal: opts?.signal });
}
```
Adding a new query-bound method without `opts` is a bug — the next person who writes a `queryFn` will silently drop the signal.
**3. Every `queryFn` MUST forward the signal it receives from TanStack Query**. The official TanStack guide (tanstack.com/query/v5/docs/framework/react/guides/query-cancellation) states: "When a query becomes out-of-date or inactive, this `signal` will become aborted." The pattern:
```ts
queryOptions({
queryKey: [...],
queryFn: ({ signal }) => api.listInbox({ signal }),
});
```
Forgetting the destructure (writing `() => api.listInbox()`) defeats every benefit of (1) and (2): TQ can't cancel hung requests when the user navigates away, and on workspace switch every stale request lives until its 30s timeout.
**Verification**: After any change to `api.ts` or a new query addition, `grep -n "queryFn: () =>" apps/mobile/data/queries/` should return zero matches. Every `queryFn` should destructure `{ signal }`.
**Why the wiring already in `data/query-client.ts` (focusManager + AppState, onlineManager + NetInfo) is not enough on its own**: focusManager triggers a *refetch attempt* when the app comes back to the foreground, but if the prior fetch promise is hanging, TQ won't start a new request — it'll keep waiting on the dead one. Only timeout + signal cancellation actually unwedges the query. The three pieces work together: signal lets TQ proactively cancel on staleness, timeout is the safety net when nothing else fires, focusManager is the "user came back, let's recheck" trigger.
### 5. Modal container selection: match container to content, don't copy the first sheet
The mobile codebase started with ~15 Modal sheets. They almost all copied the same shape (`Modal transparent fade` + hand-drawn `bg-black/40` backdrop + centered/bottom card with `maxHeight`). That shape is correct for **short action menus** (the earliest sheets), wrong for **everything else**. Once the pattern was established as "the mobile sheet style," subsequent sheets inherited it regardless of content — and inherited a different bug each time: keyboard squashing the card, `maxHeight: 380` clipping FlatLists on tall phones, `useSafeAreaInsets` returning 0 inside Modal so bottom content collides with the Home Indicator, etc.
**Choose the container by content type, not by "what the last sheet did":**
| Content shape | Container | Why |
|---|---|---|
| < 5 fixed actions, 1-2s stay, no keyboard | `Modal transparent` + bottom action card | Short, light, dim-backdrop tap-to-dismiss is correct here |
| Yes/No or one-tap confirm | `Alert.alert` | Native, accessible, no custom UI |
| One-of-N from a server-driven short list | `ActionSheetIOS.showActionSheetWithOptions` | Native iOS action sheet, no custom UI |
| < 7 fixed picker options, no search | `Modal transparent` + small centered card | Same as action card, just centered |
| Long list / search box / content view / form / anything with a keyboard | **Expo Router `presentation: "formSheet"` route** | Instantiates iOS `UISheetPresentationController`: native grabber, drag-dismiss with spring physics, stacked-card backdrop, detents — all UIKit-managed |
| Multi-screen flow / route-level full modal | Expo Router `presentation: "modal"` | Full-page slide-up, has back-stack, swipe-dismiss, deep-linkable |
**`SheetShell` is deleted.** It was a wrapper around RN core `<Modal presentationStyle="pageSheet">` which does NOT instantiate `UISheetPresentationController` — so it never had native grabber, stacked-card backdrop, or real spring physics. Every former SheetShell call site is now an Expo Router formSheet route.
**Rules for adding a new formSheet route:**
1. **File goes under the parent context** so the URL reads sensibly — issue-detail pickers under `app/(app)/[workspace]/issue/[id]/picker/<field>.tsx`; project pickers under `project/[id]/picker/<field>.tsx`; transient action sheets under `<context>/<noun>/actions.tsx`. The new-issue draft flow has its own `new-issue-picker/<field>.tsx` directory because routes can't share state with the modal that opened them — see the draft-store discussion below.
2. **Register the Stack.Screen in `app/(app)/[workspace]/_layout.tsx`** using the shared `SHEET_OPTIONS` constant. Do NOT inline the config per screen — every picker-row sheet must look and feel identical (grabber, detents, corner radius). Isolated sheets that have no neighbour to be consistent with may override `sheetAllowedDetents` only (e.g. the `menu` sheet uses `"fitToContents"` because it's ≤ 5 fixed actions and the two-snap default would leave 60% blank).
3. **Self-contained route bodies.** A picker route reads the record it needs from the TanStack Query cache (issue / project / timeline are already cached when the user gets there), calls its own mutation on submit, and `router.back()`s. No callbacks back up to a parent. The only legitimate exception is the new-issue draft flow, which uses `useNewIssueDraftStore` because the issue doesn't exist yet — there's nothing in cache to read.
4. **Header is drawn inside the body**, not by the Stack. SHEET_OPTIONS sets `headerShown: false`; the body renders its own `<View>` with title + optional right action. The native Stack header on a formSheet creates a layout dance with the grabber that doesn't match iOS sheets.
**SHEET_OPTIONS rationale (every value exists for a known bug or platform behavior):**
- `presentation: "formSheet"` — the magic that hands the screen to `UISheetPresentationController`.
- `sheetGrabberVisible: true` — the iOS native drag handle. Users don't discover the gesture without it.
- `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55 (expo/expo#42904 padding inconsistency, #42965 zero-size). Predictable two-snap presentation across every picker-row sheet is more important than shrink-wrapping; every formSheet that lives in a chip row (issue-detail / project-detail AttributeRow) uses these explicit detents so muscle memory carries across the row. Isolated sheets (no chip-row neighbour) override with `"fitToContents"` — see the workspace `menu` sheet for the canonical example.
- `sheetCornerRadius: 20` — matches RNR card radius. Without this iOS uses a larger system default that's slightly out of sync with the rest of the app.
- `contentStyle: { height: "100%" }` — safety net against the zero-size class of bugs above. Ensures the sheet body fills the allotted detent height.
**Caveats that still apply:**
- **Android falls back to a regular modal** — no rounded corners, no native drag. mobile/CLAUDE.md treats iOS as the primary target so this is acceptable, but document inline at the call site if a particular feature must work identically on both.
- **A formSheet pushed from inside a `presentation: "modal"` route is supported** by Expo Router 55 / RN Screens 4, but the back gesture from the formSheet returns to the modal, not the underlying tab. This is the right UX for the new-issue draft flow (sheet dismisses back to the form), but check the navigation graph if you're adding a sheet under a non-obvious parent.
**Carve-out — picker-row consistency wins over per-container optimisation:**
The table above says "< 7 fixed picker options → centered card". That rule
applies in isolation, but **breaks down when multiple pickers coexist in
the same chip row** (issue-detail AttributeRow is the canonical case:
status / priority / assignee / label / project / due-date all sit next
to each other). Mixing centered cards (for status/priority, short
fixed lists) with formSheet routes (for assignee/label/project, long
lists) means the user gets two different gestures depending on which
chip they tap — there's no muscle-memory carry-over.
When you find yourself building a row like this, **use the formSheet
route for every picker in the row**, even the ones a standalone
centered card would handle fine. The cost is some empty space below
57 short rows; the gain is uniform tap → slide-up-sheet +
drag-down-to-dismiss behaviour across the whole row. Linear iOS /
Things 3 / Apple Reminders all do this for the same reason.
The centered-card pattern stays correct for **isolated short menus**
(e.g. the chat-composer's "More" popover, the timeline's coalesce-
expand) where there's no neighbour to be consistent with.
### 6. Destructive swipe: reveal only, no auto-fire — always pair with haptic
iOS Mail / Linear iOS / Things: leftward swipe reveals a red Archive
button; the user **must tap it** to commit. The earlier mobile inbox
swipe auto-fired on full drag past the threshold and "felt wrong" — no
peek, easy to trigger by accident on a fast vertical scroll that
catches some horizontal motion. There is no native UX that auto-commits
a destructive action on swipe — match the platform standard.
The rule:
- `ReanimatedSwipeable` with `renderRightActions={<Pressable onPress={fireArchive} />}`.
- **No `onSwipeableOpen` auto-fire.** Drag → reveals the action; release
past threshold → action stays revealed; tap action → commit; tap
outside or drag back → cancel.
- One-shot `Haptics.impactAsync('medium')` when the drag crosses the
action width. Wire via `useAnimatedReaction(() => drag.value <= -ACTION_WIDTH, ...)`
+ `runOnJS(Haptics.impactAsync)`. The shared-value reaction runs on
the UI thread; `runOnJS` bridges to the JS-only Haptics call.
See `apps/mobile/components/inbox/swipeable-inbox-row.tsx` for the
reference implementation. When adding a new swipe-to-action row
elsewhere, copy that pattern; do not reinvent.
### 7. Tier C domain components: opportunistic upgrade only — no silent rewrites
Tier C in `apps/mobile/docs/rnr-migration.md` §4 names the domain UI
files that stay where they are but need foundation upgrades
(`ActorAvatar`, `StatusIcon`, `PriorityIcon`, `PresenceDot`, etc.).
**You don't rewrite a Tier C file just because you're rendering it in
your new feature.** That spreads scope and stalls feature PRs.
Two rules:
1. **Touch only what your PR needs to touch.** If `ActorAvatar` has
hardcoded `#71717a` and you're building an inbox feature that
*uses* `<ActorAvatar>`, leave the hex alone. Note it for a future
doc / cleanup PR.
2. **Upgrade Tier C only when you're modifying that file for a
different real reason.** E.g. adding presence to chat header → you
were going to touch `<ActorAvatar>` anyway → fold the RNR-Avatar
migration + hex → token cleanup into the same PR.
The pre-migration legacy persists because someone "while I'm in
here…"-style touched 21 files in one PR; we don't do that anymore.
Document any Tier C smells you spotted in the PR description as
follow-ups; surface for a future grouped Tier C cleanup PR.

104
apps/mobile/README.md Normal file
View File

@@ -0,0 +1,104 @@
# Multica Mobile (iOS)
Expo + React Native iOS client for Multica. Independent from web/desktop — shares only types from `@multica/core/`. See [`CLAUDE.md`](./CLAUDE.md) for the locked tech-stack baseline and import rules.
## Just want to use it on your phone? (no development)
Multica isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. One command:
```bash
pnpm ios:mobile:device:prod:release
```
This connects to the same backend as `multica.ai`, so your existing account just works.
**Prerequisites**: Mac with Xcode, a free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with [Developer Mode enabled](https://docs.expo.dev/guides/ios-developer-mode/). Walk through Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) (pick **Development build → iOS Device**) if any of that is missing.
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — created silently the first time you signed into Xcode, no setup needed. The first build downloads CocoaPods + compiles React Native from source — expect 1020 minutes. Subsequent builds reuse Xcode's cache.
**If Xcode rejects signing with "No matching provisioning profiles found"** — rare, happens if someone has claimed the default bundle id `ai.multica.mobile` on Apple's developer portal. Pick any reverse-domain you own and re-run:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
**7-day signing limit**: a free Apple ID signs builds for 7 days. After that, plug back into the Mac and re-run the command to re-sign. An Apple Developer Program account ($99/yr) extends this to 1 year.
Everything below is for app developers — you can ignore the rest if you only wanted a personal install.
## Scripts
| Command | What it does | Backend |
|---|---|---|
| `pnpm dev:mobile` | Metro only (reuse existing install) | local (`.env.development.local`) |
| `pnpm dev:mobile:staging` | Metro only (reuse existing install) | staging (`.env.staging`) |
| `pnpm dev:mobile:prod` | Metro only (reuse existing install) | production (`.env.production`) |
| `pnpm ios:mobile` | Full rebuild + install on **iOS Simulator**, Debug | local |
| `pnpm ios:mobile:staging` | Full rebuild + install on **iOS Simulator**, Debug | staging |
| `pnpm ios:mobile:prod` | Full rebuild + install on **iOS Simulator**, Debug | production |
| `pnpm ios:mobile:device` | Full rebuild + install on **USB iPhone**, Debug | local |
| `pnpm ios:mobile:device:staging` | Full rebuild + install on **USB iPhone**, Debug | staging |
| `pnpm ios:mobile:device:staging:release` | Full rebuild + install on **USB iPhone**, Release (standalone) | staging |
| `pnpm ios:mobile:device:prod` | Full rebuild + install on **USB iPhone**, Debug | production |
| `pnpm ios:mobile:device:prod:release` | Full rebuild + install on **USB iPhone**, Release (standalone) | production |
`dev:*` runs Metro only — assumes the matching variant is already installed. `ios:mobile*` does a full native rebuild + install.
Bundle id and display name switch on `APP_ENV` (see `app.config.ts`), so Dev / Staging / Production variants can coexist on the same device or simulator.
## First-time setup
`.env.staging` is committed (public staging URL). `.env.development.local` is gitignored — copy the template once:
```bash
cp apps/mobile/.env.example apps/mobile/.env.development.local
# then edit EXPO_PUBLIC_API_URL inside it to your Mac's LAN IP, e.g. http://192.168.1.42:8080
```
If your Apple ID isn't on the Multica Apple Developer team yet, also uncomment and set `EXPO_BUNDLE_IDENTIFIER_DEV` to a reverse-domain you own (e.g. `com.yourname.multica.dev`). This **only** overrides the dev variant — staging / production bundle ids are intentionally not overridable so variants can coexist.
## Build it onto your iPhone
Two paths, depending on what you want to do:
### Day-to-day development (Mac in front of you)
```bash
pnpm ios:mobile:device:staging
```
Produces a **Debug build** with `expo-dev-launcher` embedded. Every launch the app probes Metro on your Mac and pulls fresh JS — perfect for hot-reload, painful when the Mac is asleep or you're on a different WiFi.
### Standalone / "just use it" (walk away from the Mac)
```bash
pnpm ios:mobile:device:staging:release
```
Produces a **Release build**. No `expo-dev-launcher`, no Metro probe, no "Downloading…" screen. Splash → app, exactly like an App Store install. Trade-off: every JS change requires re-running this command.
Both paths share the same prerequisites: Mac with Xcode, free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with Developer Mode enabled. Follow Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) — pick **Development build → iOS Device** — if any of that is missing.
First build of either variant downloads CocoaPods + compiles React Native from source — expect 10-20 minutes. Subsequent builds reuse Xcode's DerivedData cache.
## Try it in the iOS Simulator (no iPhone needed)
```bash
pnpm ios:mobile:staging
```
Boots the simulator, builds, installs the dev-client. Faster to iterate than a device build because no signing / provisioning step. Same `dev:mobile:staging` Metro flow afterward.
## 7-day signing limit (device only)
A free Apple ID signs builds for **7 days only**, Debug and Release both. After that the app refuses to launch on the iPhone. Plug back into the Mac and re-run the corresponding `ios:mobile:device*` script to re-sign. Simulator builds are unaffected. The only workaround for the device limit is an Apple Developer Program account ($99/yr), which extends to 1 year.
## Pointing at a different backend
Edit `EXPO_PUBLIC_API_URL` in `.env.staging`, `.env.production`, or `.env.development.local` (whichever variant you're running). Then:
- For an installed **Debug build**: restart Metro (`pnpm dev:mobile:staging`) so the next JS bundle picks up the new value.
- For an installed **Release build**: re-run the `ios:mobile:device:staging:release` command — the value is baked into the embedded bundle at build time.
For local backend testing, use your Mac's LAN IP (`ipconfig getifaddr en0`), not `localhost`.

79
apps/mobile/app.config.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ExpoConfig, ConfigContext } from "expo/config";
/**
* Dynamic Expo config — replaces app.json so we can read APP_ENV at runtime
* and switch bundleIdentifier / display name for dev / staging / production.
*
* APP_ENV is set by package.json scripts:
* - dev → APP_ENV unset (treated as "development")
* - dev:staging → APP_ENV=staging
* - dev:prod → APP_ENV=production (rare; usually only for EAS build)
*/
export default ({ config }: ConfigContext): ExpoConfig => {
const env = process.env.APP_ENV ?? "development";
const isProd = env === "production";
const isStaging = env === "staging";
return {
...config,
name: isProd
? "Multica"
: isStaging
? "Multica (Staging)"
: "Multica (Dev)",
slug: "multica-mobile",
version: "0.1.0",
orientation: "portrait",
userInterfaceStyle: "automatic",
scheme: "multica",
// 1024x1024 source shared with the desktop client
// (apps/desktop/build/icon.png). Expo prebuild generates every required
// iOS icon size from this single PNG.
icon: "./assets/icon.png",
ios: {
supportsTablet: false,
// Per-variant bundle id overrides exist for one reason: an Apple ID
// can only sign bundle prefixes it owns, so contributors not on the
// Multica Apple Developer team (and external users self-building a
// personal copy against production) need to swap to a reverse-domain
// they control. Each variant has its own `_<VARIANT>` suffix and is
// only read inside that variant's branch — a generic
// `EXPO_BUNDLE_IDENTIFIER` would leak across variants (Expo CLI
// auto-loads `.env.<mode>.local` regardless of APP_ENV) and collapse
// dev / staging / prod onto a single id.
bundleIdentifier: isProd
? (process.env.EXPO_BUNDLE_IDENTIFIER_PROD ?? "ai.multica.mobile")
: isStaging
? "ai.multica.mobile.staging"
: (process.env.EXPO_BUNDLE_IDENTIFIER_DEV ?? "ai.multica.mobile.dev"),
},
plugins: [
"expo-router",
"expo-secure-store",
"@react-native-community/datetimepicker",
"react-native-enriched-markdown",
[
"expo-image-picker",
{
// iOS NSPhotoLibraryUsageDescription. Without this string in
// Info.plist, calling launchImageLibraryAsync hard-crashes on
// iOS 14+. Camera + microphone are disabled — we only ever read
// from the existing photo library.
photosPermission:
"Allow Multica to access your photos to attach images to issues and comments.",
cameraPermission: false,
microphonePermission: false,
},
],
[
"expo-build-properties",
{
ios: {
buildReactNativeFromSource: true,
},
},
],
],
extra: { APP_ENV: env },
};
};

View File

@@ -0,0 +1,149 @@
/**
* Bottom tab bar — JS `<Tabs>` from expo-router (react-navigation under the
* hood). We tried NativeTabs first but its `canPreventDefault: false`
* constraint makes "tap More → open something" impossible. JS Tabs
* supports `listeners.tabPress + e.preventDefault()`, the canonical RN
* pattern for tab-as-action.
*
* The "More" tab is **not a navigation target** — its press opens a
* DropdownMenu popover anchored above the tab. The popover is rendered
* by `<MoreTabDropdownAnchor />` as a sibling of `<Tabs>`, NOT as a
* `tabBarButton` replacement: keeping the real tab button intact means
* the icon + "More" label render identically to the other three tabs.
* We just open the dropdown imperatively from `listeners.tabPress` via
* the exposed `TriggerRef.open()`.
*
* The stub (tabs)/more.tsx file still exists only because expo-router
* requires every Tabs.Screen to have a backing route file — the press
* is preventDefault'd so we never actually navigate to it.
*
* Active / inactive tint colors are derived from the current colour
* scheme via THEME so dark mode picks contrasting values automatically.
*/
import { useRef } from "react";
import { Tabs } from "expo-router";
import { Image } from "expo-image";
import { View } from "react-native";
import type { TriggerRef } from "@rn-primitives/dropdown-menu";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import {
useInboxUnreadCount,
useChatUnreadSessionCount,
} from "@/lib/unread-counts";
import { MoreTabDropdownAnchor } from "@/components/nav/more-tab-dropdown";
// Only override backgroundColor — @react-navigation/elements Badge internally
// sets borderRadius = size/2, height = size, minWidth = size, so a single
// character renders as a perfect circle. Overriding minWidth/fontSize here
// breaks that geometry. Text color is auto-derived from backgroundColor
// luminance by Badge itself (white on brand blue).
const BADGE_STYLE = {
backgroundColor: THEME.light.brand,
};
export default function TabsLayout() {
const { colorScheme } = useColorScheme();
const t = THEME[colorScheme];
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const inboxUnread = useInboxUnreadCount(wsId);
const chatUnread = useChatUnreadSessionCount(wsId);
// Truncation aligned with web: inbox 99+, chat 9+ (matches sidebar +
// ChatFab respectively). `undefined` makes React Navigation hide the
// badge, so zero-count is a free no-op.
const inboxBadge =
inboxUnread > 0 ? (inboxUnread > 99 ? "99+" : String(inboxUnread)) : undefined;
const chatBadge =
chatUnread > 0 ? (chatUnread > 9 ? "9+" : String(chatUnread)) : undefined;
// Imperative handle into the More tab's dropdown — listeners.tabPress
// calls .open(); the @rn-primitives Trigger measures itself inside
// open() so the popover anchors to MoreTabDropdownAnchor's rect.
const moreTriggerRef = useRef<TriggerRef>(null);
return (
<View style={{ flex: 1 }}>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: t.foreground,
tabBarInactiveTintColor: t.mutedForeground,
tabBarStyle: { backgroundColor: t.background },
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="inbox"
options={{
title: "Inbox",
tabBarBadge: inboxBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:tray.fill" : "sf:tray"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="my-issues"
options={{
title: "My Issues",
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:checklist" : "sf:checklist.unchecked"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
tabBarBadge: chatBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:bubble.left.fill" : "sf:bubble.left"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="more"
options={{
title: "More",
tabBarIcon: ({ color, size }) => (
<Image
source="sf:ellipsis"
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
listeners={() => ({
tabPress: (e) => {
// Don't navigate to the (stub) /more screen — open the
// dropdown popover instead. The trigger is invisible and
// mounted in MoreTabDropdownAnchor below; ref.open() also
// measures its rect so the popover anchors correctly.
e.preventDefault();
moreTriggerRef.current?.open();
},
})}
/>
</Tabs>
<MoreTabDropdownAnchor triggerRef={moreTriggerRef} />
</View>
);
}

View File

@@ -0,0 +1,428 @@
/**
* Chat tab — single-screen IA.
*
* Layout:
* View ─ Header(center: ChatTitleButton, right: ChatSessionActions)
* ─ (NoAgentBanner?)
* ─ KeyboardAvoidingView ─ ChatMessageList (includes live status
* + timeline in its
* ListFooterComponent)
* ─ OfflineBanner
* ─ ChatComposer
*
* Session switching, agent selection, and session deletion all happen
* inside this screen via Modal sheets — there is no `/chat/[id]` sub-route.
*
* State (all local, none in Zustand):
* - activeSessionId — which session is being viewed (null = new chat blank)
* - selectedAgentId — overrides currentSession.agent_id when set (used
* when starting a new chat with a freshly-picked agent)
* - sessionSheetOpen — bottom modal visibility
* - agentPickerOpen — bottom modal visibility
*
* Side effects:
* - useChatSessionRealtime(activeSessionId) for per-record WS events
* - auto markRead when entering a session with has_unread
* - ensureSession dedupe ref for concurrent first-message sends
*
* Optimistic send burst mirrors web's chat-window.tsx send sequence
* (packages/views/chat/components/chat-window.tsx ~262-345):
* seed messages → seed pendingTask → flip activeSessionId → POST →
* patch pendingTask with server task_id + created_at.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
View,
} from "react-native";
import { router } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
ChatMessage,
ChatPendingTask,
} from "@multica/core/types";
import { api } from "@/data/api";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { agentListOptions } from "@/data/queries/agents";
import { memberListOptions } from "@/data/queries/members";
import {
chatKeys,
chatMessagesOptions,
chatSessionsOptions,
pendingChatTaskOptions,
taskMessagesOptions,
} from "@/data/queries/chat";
import {
useCreateChatSession,
useDeleteChatSession,
useMarkChatSessionRead,
} from "@/data/mutations/chat";
import {
DRAFT_NEW_SESSION,
useChatDraftsStore,
} from "@/data/stores/chat-drafts-store";
import { useChatSessionPickerStore } from "@/data/stores/chat-session-picker-store";
import { useChatSessionRealtime } from "@/data/realtime/use-chat-session-realtime";
import { canAssignAgent } from "@/lib/can-assign-agent";
import { useWorkspaceAgentAvailability } from "@/lib/workspace-agent-availability";
import { useAgentPresence } from "@/lib/use-agent-presence";
import { Header } from "@/components/ui/header";
import { ChatTitleButton } from "@/components/chat/chat-title-button";
import { ChatSessionActions } from "@/components/chat/chat-session-actions";
import { ChatMessageList } from "@/components/chat/chat-message-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { AgentPickerSheet } from "@/components/chat/agent-picker-sheet";
import { NoAgentBanner } from "@/components/chat/no-agent-banner";
import { OfflineBanner } from "@/components/chat/offline-banner";
import { useChatSelectStore } from "@/data/chat-select-store";
export default function ChatTab() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const userId = useAuthStore((s) => s.user?.id);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentPickerOpen, setAgentPickerOpen] = useState(false);
// Bridge to the chat-sessions formSheet route. Mirror local
// activeSessionId into the store so the picker can render the current
// selection's check mark; consume the picker's one-shot select request
// via useEffect.
const setStoreActiveSessionId = useChatSessionPickerStore(
(s) => s.setActiveSessionId,
);
const selectRequest = useChatSessionPickerStore((s) => s.selectRequest);
const consumeSelect = useChatSessionPickerStore((s) => s.consumeSelect);
useEffect(() => {
setStoreActiveSessionId(activeSessionId);
}, [activeSessionId, setStoreActiveSessionId]);
// ── Server state ───────────────────────────────────────────────────────
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
// ── Auto-hydrate active session on first Chat tab entry ────────────────
// Mobile-only deviation from web: web's chat-window opens to an empty
// state when no `activeSessionId` is persisted; on a phone, picking
// a session is 4 taps, so jump straight to the most recent session.
// Hydration is one-shot per workspace.
const hydratedWsRef = useRef<string | null>(null);
useEffect(() => {
if (!wsId) return;
if (hydratedWsRef.current === wsId) return;
if (sessions.length === 0) {
hydratedWsRef.current = wsId;
return;
}
hydratedWsRef.current = wsId;
setActiveSessionId(sessions[0].id);
}, [wsId, sessions]);
const { data: messages = [], isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId),
);
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId),
);
// Live execution trace for the in-flight task. `task:message` WS events
// append rows to this same cache key via `appendTaskMessage`, so the
// list/pill stay in sync without a polling fetch. `enabled` is gated by
// `isTaskMessageTaskId` inside taskMessagesOptions — optimistic ids
// never hit the network.
const { data: liveTaskMessages = [] } = useQuery(
taskMessagesOptions(pendingTask?.task_id),
);
// ── Derived ────────────────────────────────────────────────────────────
const memberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
[members, userId],
);
const availableAgents = useMemo(
() =>
agents.filter(
(a) => !a.archived_at && canAssignAgent(a, userId, memberRole),
),
[agents, userId, memberRole],
);
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
// Active agent: explicit selection wins; otherwise inherit from the
// active session; otherwise pick the first available agent.
const currentAgent: Agent | null = useMemo(() => {
if (selectedAgentId) {
return availableAgents.find((a) => a.id === selectedAgentId) ?? null;
}
if (activeSession) {
return agents.find((a) => a.id === activeSession.agent_id) ?? null;
}
return availableAgents[0] ?? null;
}, [selectedAgentId, availableAgents, activeSession, agents]);
const availability = useWorkspaceAgentAvailability();
const presenceDetail = useAgentPresence(wsId, currentAgent?.id);
const presenceAvailability =
presenceDetail === "loading" ? undefined : presenceDetail.availability;
const isArchived = activeSession?.status === "archived";
const sending = !!pendingTask?.task_id;
// ── Drafts ─────────────────────────────────────────────────────────────
const draftKey = activeSessionId ?? DRAFT_NEW_SESSION;
const draft = useChatDraftsStore((s) => s.drafts[draftKey] ?? "");
const setDraft = useChatDraftsStore((s) => s.setDraft);
const clearDraft = useChatDraftsStore((s) => s.clearDraft);
const promoteNewDraft = useChatDraftsStore((s) => s.promoteNewDraft);
// ── Realtime ───────────────────────────────────────────────────────────
useChatSessionRealtime(activeSessionId, () => {
setActiveSessionId(null);
});
// Exit text-selection mode whenever the chat tab loses focus. Expo
// Router bottom tabs stay mounted across tab switches, so a plain
// useEffect cleanup wouldn't fire — useFocusEffect is the navigation-
// aware equivalent.
useFocusEffect(
useCallback(() => () => useChatSelectStore.getState().clear(), []),
);
// ── Auto markRead while viewing a session with unread state ──────────
const isFocused = useIsFocused();
const markRead = useMarkChatSessionRead();
useEffect(() => {
if (!isFocused) return;
if (!activeSessionId) return;
if (!activeSession?.has_unread) return;
markRead.mutate(activeSessionId);
}, [isFocused, activeSessionId, activeSession?.has_unread, markRead]);
// ── Mutations ──────────────────────────────────────────────────────────
const createSession = useCreateChatSession();
const deleteSession = useDeleteChatSession();
// ── Send burst ─────────────────────────────────────────────────────────
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
const ensureSession = useCallback(
async (titleSeed: string): Promise<string | null> => {
if (activeSessionId) return activeSessionId;
if (!currentAgent) return null;
if (sessionPromiseRef.current) return sessionPromiseRef.current;
const promise = (async () => {
try {
const session = await createSession.mutateAsync({
agent_id: currentAgent.id,
title: titleSeed.slice(0, 50),
});
return session.id;
} finally {
sessionPromiseRef.current = null;
}
})();
sessionPromiseRef.current = promise;
return promise;
},
[activeSessionId, currentAgent, createSession],
);
const handleSend = useCallback(
async (content: string, attachmentIds: string[] = []) => {
if (!currentAgent) return;
const isNewSession = !activeSessionId;
const sessionId = await ensureSession(content);
if (!sessionId) return;
const sentAt = new Date().toISOString();
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content,
task_id: null,
created_at: sentAt,
};
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? [...old, optimistic] : [optimistic],
);
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: `optimistic-${optimistic.id}`,
status: "queued",
created_at: sentAt,
});
if (isNewSession) {
promoteNewDraft(sessionId);
setActiveSessionId(sessionId);
}
try {
const result = await api.sendChatMessage(sessionId, content, {
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
});
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
created_at: result.created_at,
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
clearDraft(sessionId);
} catch (err) {
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? old.filter((m) => m.id !== optimistic.id) : old,
);
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
throw err;
}
},
[
activeSessionId,
currentAgent,
ensureSession,
qc,
promoteNewDraft,
clearDraft,
],
);
// ── Cancel in-flight ───────────────────────────────────────────────────
const handleStop = useCallback(() => {
if (!pendingTask?.task_id || !activeSessionId) return;
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
void api.cancelTaskById(pendingTask.task_id).catch(() => {
// Silent — task may have already terminated server-side.
});
}, [pendingTask?.task_id, activeSessionId, qc]);
// ── Header / sheet actions ─────────────────────────────────────────────
const handleNewChat = useCallback(() => {
if (availableAgents.length > 1) {
setAgentPickerOpen(true);
return;
}
setSelectedAgentId(null);
setActiveSessionId(null);
}, [availableAgents.length]);
const handlePickAgent = useCallback((agent: Agent) => {
setSelectedAgentId(agent.id);
setActiveSessionId(null);
}, []);
// Apply the user's pick from the chat-sessions route (or "no session"
// when they delete the active one in the sheet).
useEffect(() => {
if (!selectRequest) return;
setSelectedAgentId(null);
setActiveSessionId(selectRequest.id);
consumeSelect();
}, [selectRequest, consumeSelect]);
const handleDeleteActive = useCallback(() => {
if (!activeSession) return;
Alert.alert(
"Delete this chat?",
activeSession.title || "Untitled chat",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
const id = activeSession.id;
setActiveSessionId(null);
deleteSession.mutate(id);
},
},
],
{ cancelable: true },
);
}, [activeSession, deleteSession]);
// ── Composer disabled-state ────────────────────────────────────────────
const disabled =
!currentAgent || availability === "none" || isArchived === true;
const disabledReason = !currentAgent
? "No agent selected"
: availability === "none"
? "No agents in this workspace"
: isArchived
? "This chat is archived"
: undefined;
return (
<View className="flex-1 bg-background">
<Header
center={
<ChatTitleButton
currentSession={activeSession}
currentAgent={currentAgent}
onPress={() => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/chat-sessions",
params: { workspace: wsSlug },
});
}}
/>
}
right={
<ChatSessionActions
showMore={!!activeSession}
onMorePress={handleDeleteActive}
onNewPress={handleNewChat}
/>
}
/>
{availability === "none" ? <NoAgentBanner /> : null}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<ChatMessageList
messages={messages}
loading={messagesLoading}
hasSessions={sessions.length > 0}
agentName={currentAgent?.name}
onPickPrompt={(text) => setDraft(draftKey, text)}
pendingTask={pendingTask}
liveTaskMessages={liveTaskMessages}
availability={presenceAvailability}
/>
<OfflineBanner
agentName={currentAgent?.name}
availability={presenceAvailability}
/>
<ChatComposer
value={draft}
onChangeText={(next) => setDraft(draftKey, next)}
onSend={handleSend}
onStop={handleStop}
sending={sending}
disabled={disabled}
disabledReason={disabledReason}
/>
</KeyboardAvoidingView>
<AgentPickerSheet
visible={agentPickerOpen}
agents={availableAgents}
currentAgentId={currentAgent?.id ?? null}
onPick={handlePickAgent}
onClose={() => setAgentPickerOpen(false)}
/>
</View>
);
}

View File

@@ -0,0 +1,199 @@
import { useMemo } from "react";
import {
ActionSheetIOS,
Alert,
FlatList,
View,
} from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Header } from "@/components/ui/header";
import { IconButton } from "@/components/ui/icon-button";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { SwipeableInboxRow } from "@/components/inbox/swipeable-inbox-row";
import { inboxListOptions } from "@/data/queries/inbox";
import {
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
useArchiveInbox,
useMarkAllInboxRead,
useMarkInboxRead,
} from "@/data/mutations/inbox";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { deduplicateInboxItems } from "@/lib/inbox-display";
export default function Inbox() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { colorScheme } = useColorScheme();
const { data: rawItems, isLoading, error, refetch, isRefetching } = useQuery(
inboxListOptions(wsId),
);
// Dedup + drop archived to match web/desktop. See CLAUDE.md
// "Behavioral parity" → inbox dedup incident.
const data = useMemo(
() => deduplicateInboxItems(rawItems ?? []),
[rawItems],
);
const markRead = useMarkInboxRead();
const markAllRead = useMarkAllInboxRead();
const archive = useArchiveInbox();
const archiveAll = useArchiveAllInbox();
const archiveAllRead = useArchiveAllReadInbox();
const archiveCompleted = useArchiveCompletedInbox();
const onPressItem = (item: InboxItem) => {
if (!item.read) {
// Optimistic read flip lives in useMarkInboxRead.onMutate — fires
// setQueryData synchronously before the cancelQueries await, so the
// row is already styled "read" by the time iOS captures the source
// snapshot for the native stack push transition.
markRead.mutate(item.id);
}
if (item.issue_id && wsSlug) {
router.push({
pathname: "/[workspace]/issue/[id]",
params: {
workspace: wsSlug,
id: item.issue_id,
highlight: item.details?.comment_id,
h: String(Date.now()),
},
});
}
};
// Trailing batch menu — mirrors web's dropdown
// (packages/views/inbox/components/inbox-page.tsx). "Mark all read" is
// first (most common batch op); "Archive all" is destructive so it gets
// the iOS red treatment + Alert confirm.
const onPressMenu = () => {
const options = [
"Cancel",
"Mark all read",
"Archive all read",
"Archive completed",
"Archive all",
];
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: 4,
title: "Inbox",
},
(i) => {
if (i === 1) markAllRead.mutate();
else if (i === 2) archiveAllRead.mutate();
else if (i === 3) archiveCompleted.mutate();
else if (i === 4) {
Alert.alert(
"Archive all?",
"This archives every inbox item, read or unread. You can still find them via the issue pages.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Archive all",
style: "destructive",
onPress: () => archiveAll.mutate(),
},
],
);
}
},
);
};
return (
<View className="flex-1 bg-background">
<Header
title="Inbox"
right={
<>
<IconButton
name="ellipsis-horizontal"
onPress={onPressMenu}
accessibilityLabel="Inbox actions"
/>
<HeaderActions />
</>
}
/>
{isLoading ? (
<InboxLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load inbox:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : !data || data.length === 0 ? (
<InboxEmpty iconColor={THEME[colorScheme].mutedForeground} />
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-16" />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<SwipeableInboxRow
item={item}
onPress={() => onPressItem(item)}
onArchive={() => archive.mutate(item.id)}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
// Loading state — 6 row-shaped Skeletons matching InboxRow's layout
// (avatar circle + two text lines). Perceived perf wins over a centered
// spinner because the eye immediately sees the list-like structure.
function InboxLoading() {
return (
<View className="px-4 pt-4 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<View key={i} className="flex-row gap-3">
<Skeleton className="size-9 rounded-full" />
<View className="flex-1 gap-2 pt-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</View>
</View>
))}
</View>
);
}
function InboxEmpty({ iconColor }: { iconColor: string }) {
return (
<View className="flex-1 items-center justify-center px-8 gap-3">
<Ionicons name="mail-open-outline" size={42} color={iconColor} />
<Text className="text-base font-medium text-foreground text-center">
Inbox zero
</Text>
<Text className="text-sm text-muted-foreground text-center">
When someone @mentions you, assigns an issue, or an agent finishes a
task, it shows up here.
</Text>
</View>
);
}

View File

@@ -0,0 +1,16 @@
/**
* Stub route. The "More" tab in (tabs)/_layout.tsx intercepts tabPress and
* pushes /[workspace]/menu (formSheet route) instead of navigating here,
* so this screen is never rendered through normal use. expo-router still
* requires a file to exist at this path to register the Tabs.Screen entry.
*
* If a deep link or stale tab state somehow lands the user here, bounce
* to inbox so they don't see a blank screen.
*/
import { Redirect } from "expo-router";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function MoreStub() {
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
return <Redirect href={slug ? `/${slug}/inbox` : "/select-workspace"} />;
}

View File

@@ -0,0 +1,373 @@
/**
* "My Issues" tab. Three scopes — assigned / created / agents — mirroring
* web's `packages/views/my-issues/components/my-issues-page.tsx:48-65`. The
* `agents` scope label is "Agents and Squads" because the backend predicate
* (`involves_user_id`, MUL-2397) surfaces both the user's owned agents and
* squads they're involved in (member / leader / has an owned agent inside).
*
* Issues are grouped by status using SectionList in `BOARD_STATUSES` order;
* empty status sections are filtered out so the screen doesn't fill with
* "(0)" headers. Section grouping uses `BOARD_STATUSES` (cancelled excluded)
* to match web — same source `packages/views/my-issues/components/my-issues-page.tsx:117-125`.
*
* Status + Priority filters mirror web's MyIssuesHeader filter sub-menus.
* Filter state lives in `useMyIssuesViewStore` and is cleared on workspace
* change via the shared `useClearFiltersOnWorkspaceChange` hook.
*/
import { useMemo } from "react";
import { Pressable, SectionList, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { Issue, IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Header } from "@/components/ui/header";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
import { IssuesLoading } from "@/components/issue/issues-loading";
import {
buildMyIssuesFilter,
myIssueListOptions,
} from "@/data/queries/my-issues";
import type { MyIssuesScope } from "@/data/queries/issue-keys";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useMyIssuesViewStore } from "@/data/stores/my-issues-view-store";
import { useClearFiltersOnWorkspaceChange } from "@/lib/use-clear-filters-on-workspace-change";
import {
BOARD_STATUSES,
PRIORITY_LABEL,
STATUS_LABEL,
} from "@/lib/issue-status";
import { filterIssues } from "@/lib/filter-issues";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
// Mobile pill row has tight width on SE3 (375pt). Three pills + Filter icon
// must fit in 343pt usable space, so the agents scope renders "Agents" — the
// full "Agents and Squads" label (~135pt) blows past safe limits and breaks
// under Dynamic Type. Semantics unchanged: same backend predicate
// (`involves_user_id`, MUL-2397) covers owned agents + related squads; the
// empty state copy still says "agents or squads".
const SCOPES: { value: MyIssuesScope; label: string }[] = [
{ value: "assigned", label: "Assigned" },
{ value: "created", label: "Created" },
{ value: "agents", label: "Agents" },
];
type IssueSection = { status: IssueStatus; data: Issue[] };
export default function MyIssues() {
const userId = useAuthStore((s) => s.user?.id ?? null);
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const scope = useMyIssuesViewStore((s) => s.scope);
const setScope = useMyIssuesViewStore((s) => s.setScope);
const statusFilters = useMyIssuesViewStore((s) => s.statusFilters);
const priorityFilters = useMyIssuesViewStore((s) => s.priorityFilters);
const openFilter = () => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/issues-filter",
params: { workspace: wsSlug, scope: "my" },
});
};
useClearFiltersOnWorkspaceChange(
useMyIssuesViewStore.getState().clearFilters,
wsId,
);
const filter = useMemo(
() => (userId ? buildMyIssuesFilter(scope, userId) : { assignee_id: "" }),
[scope, userId],
);
const { data, isLoading, error, refetch, isRefetching } = useQuery({
...myIssueListOptions(wsId, scope, filter),
enabled: !!wsId && !!userId,
});
// Apply client-side status + priority filter. Mirrors the predicate at
// packages/views/issues/utils/filter.ts:30-34 via filterIssues().
const filtered = useMemo(
() => filterIssues(data ?? [], statusFilters, priorityFilters),
[data, statusFilters, priorityFilters],
);
// When statusFilters is non-empty, intersect visible status order with it
// so hidden statuses don't render an empty section header. Uses
// BOARD_STATUSES (cancelled excluded) to match web.
const sections = useMemo<IssueSection[]>(() => {
if (filtered.length === 0) return [];
const byStatus = new Map<IssueStatus, Issue[]>();
for (const issue of filtered) {
const list = byStatus.get(issue.status);
if (list) list.push(issue);
else byStatus.set(issue.status, [issue]);
}
const visibleStatuses = statusFilters.length > 0
? BOARD_STATUSES.filter((s) => statusFilters.includes(s))
: BOARD_STATUSES;
return visibleStatuses
.map((status) => ({ status, data: byStatus.get(status) ?? [] }))
.filter((s) => s.data.length > 0);
}, [filtered, statusFilters]);
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 0;
const showEmptyState =
!isLoading && !error && filtered.length === 0;
return (
<View className="flex-1 bg-background">
<Header title="My Issues" right={<HeaderActions />} />
<ScopeToolbar
scopes={SCOPES}
scope={scope}
onChange={(v) => setScope(v)}
onOpenFilter={openFilter}
hasActiveFilters={hasActiveFilters}
/>
{hasActiveFilters ? (
<ActiveFilterChips
statusFilters={statusFilters}
priorityFilters={priorityFilters}
onClearStatus={(s) =>
useMyIssuesViewStore.getState().toggleStatusFilter(s)
}
onClearPriority={(p) =>
useMyIssuesViewStore.getState().togglePriorityFilter(p)
}
/>
) : null}
{isLoading ? (
<IssuesLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : showEmptyState ? (
<EmptyState
message={
hasActiveFilters
? "No issues match the current filters."
: emptyMessageForScope(scope)
}
/>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled={false}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
renderSectionHeader={({ section }) => (
<SectionHeader
status={section.status}
count={section.data.length}
/>
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<IssueRow
issue={item}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/issue/${item.id}`);
}}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
/**
* Outline icon button matching the pill height so the toolbar row reads as
* one visual group. Mirrors web `IssuesHeader` / `MyIssuesHeader` filter
* trigger (`packages/views/my-issues/components/my-issues-header.tsx:174`),
* which is also `variant="outline"` + icon-sized — NOT the ghost-style we'd
* get from <IconButton>. Square (`w-9`) with `px-0` to suppress the sm
* default `px-3`.
*/
function FilterButton({
onPress,
hasActiveFilters,
}: {
onPress: () => void;
hasActiveFilters: boolean;
}) {
const { colorScheme } = useColorScheme();
return (
<View style={{ position: "relative" }} className="ml-2">
<Button
variant="outline"
size="sm"
onPress={onPress}
accessibilityLabel="Filter"
className="w-9 px-0"
>
<Ionicons
name="options-outline"
size={16}
color={THEME[colorScheme].mutedForeground}
/>
</Button>
{hasActiveFilters ? (
<View
pointerEvents="none"
className="absolute top-1 right-1 size-1.5 rounded-full bg-brand"
/>
) : null}
</View>
);
}
/**
* Toolbar row mirroring web `MyIssuesHeader` / `IssuesHeader`
* (`packages/views/my-issues/components/my-issues-header.tsx:138-163`):
* left-aligned scope pill group + right-side Filter icon (red dot when
* filters are active). Replaces the previous full-width segmented tabs +
* Filter-in-title-bar split — keeps scope and the filter affordance in the
* same row, because they both control the list directly below.
*/
function ScopeToolbar<S extends string>({
scopes,
scope,
onChange,
onOpenFilter,
hasActiveFilters,
}: {
scopes: { value: S; label: string }[];
scope: S;
onChange: (value: S) => void;
onOpenFilter: () => void;
hasActiveFilters: boolean;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-2 pb-2">
<View className="flex-row items-center gap-1 flex-shrink min-w-0">
{scopes.map((s) => {
const active = scope === s.value;
return (
<Button
key={s.value}
variant="outline"
size="sm"
onPress={() => onChange(s.value)}
className={active ? "bg-accent" : ""}
accessibilityState={{ selected: active }}
>
<Text
numberOfLines={1}
className={active ? "text-accent-foreground" : "text-muted-foreground"}
>
{s.label}
</Text>
</Button>
);
})}
</View>
<FilterButton
onPress={onOpenFilter}
hasActiveFilters={hasActiveFilters}
/>
</View>
);
}
function ActiveFilterChips({
statusFilters,
priorityFilters,
onClearStatus,
onClearPriority,
}: {
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
onClearStatus: (s: IssueStatus) => void;
onClearPriority: (p: IssuePriority) => void;
}) {
return (
<View className="flex-row flex-wrap gap-1.5 px-4 pb-2">
{statusFilters.map((s) => (
<Chip key={`s-${s}`} label={STATUS_LABEL[s]} onClear={() => onClearStatus(s)} />
))}
{priorityFilters.map((p) => (
<Chip key={`p-${p}`} label={PRIORITY_LABEL[p]} onClear={() => onClearPriority(p)} />
))}
</View>
);
}
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
const { colorScheme } = useColorScheme();
return (
<Pressable
onPress={onClear}
className="flex-row items-center gap-1 pl-2.5 pr-2 py-1 rounded-full border border-border bg-secondary/40 active:bg-secondary"
>
<Text className="text-xs text-foreground">{label}</Text>
<Ionicons
name="close"
size={12}
color={THEME[colorScheme].mutedForeground}
/>
</Pressable>
);
}
function SectionHeader({
status,
count,
}: {
status: IssueStatus;
count: number;
}) {
return (
<View className="flex-row items-center gap-2 px-4 py-2 bg-background">
<StatusIcon status={status} size={14} />
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{STATUS_LABEL[status]}
</Text>
<Text className="text-xs text-muted-foreground/60">{count}</Text>
</View>
);
}
function EmptyState({ message }: { message: string }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground text-center">
{message}
</Text>
</View>
);
}
function emptyMessageForScope(scope: MyIssuesScope): string {
switch (scope) {
case "assigned":
return "No issues assigned to you.";
case "created":
return "You haven't created any issues.";
case "agents":
return "No issues assigned to your agents or squads yet.";
}
}

View File

@@ -0,0 +1,339 @@
import { useEffect } from "react";
import type { ComponentProps } from "react";
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useWorkspaceStore } from "@/data/workspace-store";
import { RealtimeProvider } from "@/data/realtime/realtime-provider";
import { useInboxRealtime } from "@/data/realtime/use-inbox-realtime";
import { useIssuesRealtime } from "@/data/realtime/use-issues-realtime";
import { useMyIssuesRealtime } from "@/data/realtime/use-my-issues-realtime";
import { useChatSessionsRealtime } from "@/data/realtime/use-chat-sessions-realtime";
import { useProjectsRealtime } from "@/data/realtime/use-projects-realtime";
import { usePinsRealtime } from "@/data/realtime/use-pins-realtime";
import { usePresenceRealtime } from "@/data/realtime/use-presence-realtime";
import { useWorkspacePresencePrefetch } from "@/lib/use-workspace-presence-prefetch";
import { ModalCloseButton } from "@/components/ui/modal-close-button";
import { useNewIssueDraftResetOnWorkspaceChange } from "@/data/stores/new-issue-draft-store";
import { useNewProjectDraftResetOnWorkspaceChange } from "@/data/stores/new-project-draft-store";
import { useChatSessionPickerResetOnWorkspaceChange } from "@/data/stores/chat-session-picker-store";
/**
* Shared Stack.Screen options for every iOS formSheet-presented sheet route.
*
* Why these specific values:
* - `presentation: "formSheet"` instantiates iOS
* UISheetPresentationController — native grabber, stacked-card backdrop,
* drag-to-dismiss spring physics, detents.
* - `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The
* ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55
* (expo/expo#42904 padding inconsistency, expo/expo#42965 zero-size).
* Predictable two-snap presentation across every picker-row sheet >
* shrink-wrap; this is the right default for sheets that sit next to
* other sheets in the same chip row (issue / project AttributeRow) so
* the user gets the same gesture regardless of which chip they tap.
* Isolated sheets that have no neighbour to be consistent with (e.g.
* the workspace `menu` sheet) override this with `"fitToContents"`
* to avoid the large blank area below their content.
* - `sheetGrabberVisible: true` — surfaces the iOS native drag handle
* so users discover the gesture.
* - `contentStyle.height: "100%"` — safety net against the same
* zero-size class of bugs above; ensures the sheet body fills the
* allotted detent.
* - `headerShown: false` — every sheet body draws its own header (title
* + optional right action). The native Stack header would double up.
*/
const SHEET_OPTIONS: ComponentProps<typeof Stack.Screen>["options"] = {
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.6, 0.95],
sheetCornerRadius: 20,
contentStyle: { flex: 1 },
headerShown: false,
};
/**
* Cold-start deep-link anchor. Expo Router otherwise treats whatever
* route resolves the URL as the root of the stack — if the user opens a
* notification that targets `issue/[id]/picker/status` directly, they
* land on the formSheet with NO parent under it, no way to go back to
* the tabs. `anchor: "(tabs)"` tells the router to mount the tab UI as
* the implicit underlying screen so back/swipe-dismiss returns the user
* to a sensible base state.
*/
export const unstable_settings = { anchor: "(tabs)" } as const;
/**
* Mounts every per-feature realtime subscription. Lives inside
* RealtimeProvider so the WSClient context is available, and stays alive
* for the whole workspace session — the inbox unread count must keep
* refreshing even while the user is on an issue page or settings, not
* just when the inbox tab is foregrounded.
*
* Add new realtime feature hooks here as they land (issue, chat, etc).
*/
function RealtimeSubscriptions() {
useInboxRealtime();
useIssuesRealtime();
useMyIssuesRealtime();
useChatSessionsRealtime();
useProjectsRealtime();
usePinsRealtime();
// Presence: warm the three queries up front so avatars don't flash a
// dotless first render, and listen for daemon/agent/task events to keep
// the runtime + snapshot caches fresh. See use-presence-realtime.ts for
// the deliberately-skipped high-frequency events.
useWorkspacePresencePrefetch();
usePresenceRealtime();
return null;
}
/**
* Workspace context layout. Reads the slug from the URL (the route is the
* source of truth — see apps/mobile/CLAUDE.md "Behavioral parity"), validates
* membership against the workspaces list, then syncs id+slug into the
* Zustand store so ApiClient.fetch can read the slug synchronously when
* injecting the X-Workspace-Slug header.
*
* If the slug doesn't match any workspace the user belongs to, redirect to
* /select-workspace (covers stale persisted slugs after the user lost
* membership, deep links to wrong slugs, etc.).
*/
export default function WorkspaceLayout() {
const { workspace: slug } = useLocalSearchParams<{ workspace: string }>();
const { data: workspaces, isLoading } = useQuery(workspaceListOptions());
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const matched = workspaces?.find((w) => w.slug === slug);
useEffect(() => {
if (matched) {
setCurrentWorkspace(matched.id, matched.slug);
}
}, [matched, setCurrentWorkspace]);
// Wipe cross-route Zustand draft stores whenever the active workspace
// changes — a draft picked under workspace A (assignee id, draft
// session id, etc.) is invalid in workspace B and must not leak.
useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null);
useNewProjectDraftResetOnWorkspaceChange(matched?.id ?? null);
useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null);
// Wait for the workspaces list before deciding membership — otherwise a
// valid deep link would briefly redirect away on cold start.
if (isLoading) return null;
if (!matched) return <Redirect href="/select-workspace" />;
// Tabs hide their own header; pushed screens (issue/[id]) get a native
// iOS Stack header with the standard back button + swipe-to-dismiss.
return (
<RealtimeProvider>
<RealtimeSubscriptions />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="issue/[id]"
options={{
title: "Issue",
headerBackTitle: "Back",
}}
/>
<Stack.Screen
name="project/[id]"
options={{
title: "Project",
headerBackTitle: "Back",
}}
/>
<Stack.Screen
name="project/[id]/edit"
options={{
title: "Edit Project",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="issue/[id]/edit"
options={{
title: "Edit Issue",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="project/new"
options={{
title: "New Project",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
{/* Issue-detail formSheet pickers. All share the same sheet config:
explicit numeric detents to dodge expo/expo#42904+#42965 (the
`fitToContents` zero-size / padding bugs on iOS 26 + Expo 55),
iOS native grabber, and contentStyle.height=100% as a safety
net against the same zero-size class of bugs. */}
<Stack.Screen
name="issue/[id]/picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="issue/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
{/* Experiment: assignee uses iOS-native nav header + UISearchController
instead of the body-rendered header pattern in SHEET_OPTIONS.
Eliminates the #3634 overlap class of bugs and the focus-loss
footgun of a custom TextInput inside ListHeaderComponent. The
route file wires `headerSearchBarOptions` via setOptions. If this
proves out, propagate to label / project / other search pickers
and update CLAUDE.md Lesson 6 with a carve-out. */}
<Stack.Screen
name="issue/[id]/picker/assignee"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="issue/[id]/picker/label"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="mention-picker"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Mention",
}}
/>
<Stack.Screen
name="issue/[id]/picker/project"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="issue/[id]/picker/due-date"
options={SHEET_OPTIONS}
/>
<Stack.Screen name="issue/[id]/runs" options={SHEET_OPTIONS} />
{/* Full emoji picker for a comment reaction. Pushed from the "+"
button inside the comment long-press tapback row — see
components/issue/comment-context-menu.tsx. */}
<Stack.Screen
name="issue/[id]/comment/[commentId]/emoji-picker"
options={SHEET_OPTIONS}
/>
{/* Project-detail formSheet pickers. */}
<Stack.Screen
name="project/[id]/picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/lead"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/add-resource"
options={SHEET_OPTIONS}
/>
{/* New-issue draft formSheet pickers — stacked on top of the
new-issue.tsx Stack.Screen (which is itself a `modal`).
Expo Router 55 / RN Screens 4 support a formSheet pushed on top
of a modal in the same Stack. */}
<Stack.Screen
name="new-issue-picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/priority"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/assignee"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="new-issue-picker/project"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/due-date"
options={SHEET_OPTIONS}
/>
{/* New-project draft formSheet pickers — same pattern as
new-issue-picker/*. Stacked on top of `project/new` (a modal). */}
<Stack.Screen
name="new-project-picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-project-picker/priority"
options={SHEET_OPTIONS}
/>
{/* Shared filter sheet for My Issues and the workspace Issues page —
chooses the right view-store via `?scope=my|all` URL param. */}
<Stack.Screen name="issues-filter" options={SHEET_OPTIONS} />
{/* Chat session-switch sheet. */}
<Stack.Screen name="chat-sessions" options={SHEET_OPTIONS} />
{/* Workspace switcher — reached from the More popover's collapsed
WorkspaceCard. Two-step (pick → iOS Alert confirm → switch). */}
<Stack.Screen name="switch-workspace" options={SHEET_OPTIONS} />
<Stack.Screen
name="more/issues"
options={{ title: "Issues", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/projects"
options={{ title: "Projects", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/agents"
options={{ title: "Agents", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/pins"
options={{ title: "Pinned", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/settings"
options={{ title: "Settings", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/settings/profile"
options={{ title: "Profile", headerBackTitle: "Settings" }}
/>
<Stack.Screen
name="more/settings/notifications"
options={{ title: "Notifications", headerBackTitle: "Settings" }}
/>
<Stack.Screen
name="new-issue"
options={{
title: "New Issue",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="search"
options={{
title: "Search",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
</Stack>
</RealtimeProvider>
);
}

View File

@@ -0,0 +1,121 @@
/**
* Chat session-switch sheet — presented as a formSheet by the parent Stack.
* Reads the session list from the chat cache and writes the user's pick
* through a shared "active session" store so the chat tab picks it up on
* dismiss.
*
* Why a tiny dedicated store: the chat tab's `activeSessionId` used to live
* as a `useState` inside `chat.tsx`, but now that session picking happens
* on a separate route screen, we need a cross-screen channel. Same minimum
* pattern as `useNewIssueDraftStore` for the new-issue form.
*/
import { Alert, Pressable, ScrollView, View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { ChatSession } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { chatSessionsOptions } from "@/data/queries/chat";
import { useDeleteChatSession } from "@/data/mutations/chat";
import { useChatSessionPickerStore } from "@/data/stores/chat-session-picker-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { cn } from "@/lib/utils";
export default function ChatSessionsRoute() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const activeSessionId = useChatSessionPickerStore((s) => s.activeSessionId);
const requestSelect = useChatSessionPickerStore((s) => s.requestSelect);
const deleteSession = useDeleteChatSession();
const confirmDelete = (session: ChatSession) => {
Alert.alert(
"Delete this chat?",
session.title || "Untitled chat",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteSession.mutate(session.id);
// If we just deleted the active one, the chat tab clears its
// local activeSessionId via the picker-store request.
if (session.id === activeSessionId) {
requestSelect(null);
}
},
},
],
{ cancelable: true },
);
};
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">Chats</Text>
</View>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{sessions.length === 0 ? (
<View className="px-4 py-8">
<Text className="text-sm text-muted-foreground text-center">
No chats yet.
</Text>
</View>
) : (
sessions.map((session) => {
const selected = session.id === activeSessionId;
const archived = session.status === "archived";
return (
<Pressable
key={session.id}
onPress={() => {
requestSelect(session.id);
router.back();
}}
onLongPress={() => confirmDelete(session)}
className={cn(
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
selected && "bg-secondary/60",
)}
>
<View
className={cn(
"h-2 w-2 rounded-full",
session.has_unread ? "bg-primary" : "bg-transparent",
)}
/>
<ActorAvatar
type="agent"
id={session.agent_id}
size={32}
showPresence
/>
<View className="flex-1">
<Text
className={cn(
"text-sm text-foreground",
session.has_unread && "font-semibold",
)}
numberOfLines={1}
>
{session.title || "Untitled chat"}
</Text>
{archived ? (
<Text className="text-xs text-muted-foreground mt-0.5">
archived
</Text>
) : null}
</View>
{selected ? (
<Text className="text-sm text-primary font-semibold"></Text>
) : null}
</Pressable>
);
})
)}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,222 @@
/**
* Issue detail screen.
*
* Read-mostly timeline with an inline comment composer pinned to the
* bottom (`<InlineCommentComposer>`). The composer is a single
* `<TextInput>` + mention suggestion bar — no modal route, no toolbar,
* no draft persistence. Sticks to the keyboard via `KeyboardStickyView`.
*
* Header note: the parent _layout.tsx already declares the `issue/[id]`
* Stack.Screen with title "Issue". We override that here once the data
* lands so the navigation bar shows `MUL-123` (Linear-style).
*/
import { useCallback, useEffect } from "react";
import {
ActionSheetIOS,
ActivityIndicator,
Alert,
Linking,
View,
} from "react-native";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { Issue } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { TimelineList } from "@/components/issue/timeline-list";
import { AgentHeaderBadge } from "@/components/issue/agent-header-badge";
import { InlineCommentComposer } from "@/components/issue/inline-comment-composer";
import {
issueDetailOptions,
issueKeys,
issueTimelineOptions,
} from "@/data/queries/issues";
import { useDeleteIssue } from "@/data/mutations/issues";
import { pinListOptions } from "@/data/queries/pins";
import { useCreatePin, useDeletePin } from "@/data/mutations/pins";
import { useAuthStore } from "@/data/auth-store";
import { useIssueRealtime } from "@/data/realtime/use-issue-realtime";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useViewedIssuesStore } from "@/data/viewed-issues-store";
import { useCommentSelectStore } from "@/data/comment-select-store";
import { useReplyTargetStore } from "@/data/stores/reply-target-store";
export default function IssueDetail() {
// `highlight` + `h` come from inbox deep-link (apps/mobile/app/(app)/
// [workspace]/(tabs)/inbox.tsx). `highlight` is the target comment id;
// `h` is a per-tap nonce so re-tapping the same row re-fires the
// scroll-and-flash effect.
const { id, workspace: wsSlug, highlight, h } = useLocalSearchParams<{
id: string;
workspace: string;
highlight?: string;
h?: string;
}>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const qc = useQueryClient();
const detail = useQuery(issueDetailOptions(wsId, id));
const timeline = useQuery(issueTimelineOptions(wsId, id));
// Subscribe to per-issue WS events: status/priority/assignee/label
// changes, comments, activity, reactions, agent task progress.
// Mounted with `id` — cleans up automatically on navigate-away.
// If another client deletes the issue we're viewing, pop back so the
// user isn't stranded on a 404 detail page.
useIssueRealtime(id, () => router.back());
// Track viewed issues so the chat composer's `@` suggestion bar can
// surface "Recent" — the user just looked at MUL-123, likely wants to
// ask the agent about it next. Workspace-scoped + in-memory; see
// data/viewed-issues-store.ts.
useEffect(() => {
if (wsId && id) {
useViewedIssuesStore.getState().push(wsId, id);
}
}, [wsId, id]);
// Screen-scoped composer state — clear on unmount so re-entering the
// issue starts from a clean slate (no stale text-selection comment id,
// no stale "Replying to X" target). Both stores are singletons used by
// the long-press action sheet.
useEffect(() => {
return () => {
useCommentSelectStore.getState().clear();
useReplyTargetStore.getState().clear();
};
}, []);
const onRefresh = useCallback(async () => {
await Promise.all([
detail.refetch(),
qc.invalidateQueries({ queryKey: issueKeys.timeline(wsId, id) }),
]);
}, [detail, qc, wsId, id]);
const issue = detail.data;
const deleteIssue = useDeleteIssue();
const userId = useAuthStore((s) => s.user?.id ?? null);
const { data: pins } = useQuery(pinListOptions(wsId, userId));
const isPinned =
!!issue &&
!!pins?.some((p) => p.item_type === "issue" && p.item_id === issue.id);
const createPin = useCreatePin();
const deletePin = useDeletePin();
// Three-dot menu: Pin/Unpin / Copy link / Open on web (if web URL set) /
// Delete. Mirrors apps/mobile/app/(app)/[workspace]/project/[id].tsx — same
// ActionSheetIOS + Alert.alert confirm pattern. Property edits (status,
// priority, assignee, due_date) live on the IssueHeaderCard chips inside
// the timeline list, not in this menu — one entry per action.
const onPressMore = useCallback(() => {
if (!issue || !wsSlug) return;
const webUrl = process.env.EXPO_PUBLIC_WEB_URL;
const issueLink = webUrl
? `${webUrl}/${wsSlug}/issue/${issue.identifier}`
: null;
const options: string[] = ["Cancel"];
options.push(isPinned ? "Unpin" : "Pin");
options.push("Edit details");
if (issueLink) options.push("Copy link");
if (issueLink) options.push("Open on web");
options.push("Delete issue");
const destructiveIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: destructiveIndex,
title: issue.identifier,
},
(i) => {
const label = options[i];
if (label === "Pin") {
createPin.mutate({ item_type: "issue", item_id: issue.id });
} else if (label === "Unpin") {
deletePin.mutate({ itemType: "issue", itemId: issue.id });
} else if (label === "Edit details") {
if (wsSlug) router.push(`/${wsSlug}/issue/${issue.id}/edit`);
} else if (label === "Copy link" && issueLink) {
Clipboard.setStringAsync(issueLink);
} else if (label === "Open on web" && issueLink) {
Linking.openURL(issueLink);
} else if (label === "Delete issue") {
confirmDelete(issue, () =>
deleteIssue.mutate(issue.id, {
onSuccess: () => router.back(),
}),
);
}
},
);
}, [issue, wsSlug, deleteIssue, isPinned, createPin, deletePin]);
return (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
title: issue?.identifier ?? "Issue",
headerBackTitle: "Back",
headerRight: issue
? () => (
<View className="flex-row items-center gap-2">
{/* Ambient agent-working badge — renders null when no
* active tasks, so it doesn't crowd the header in the
* common case. See agent-header-badge.tsx. */}
<AgentHeaderBadge issueId={id} />
<IconButton
name="ellipsis-horizontal"
onPress={onPressMore}
accessibilityLabel="Issue actions"
/>
</View>
)
: undefined,
}}
/>
{detail.isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : detail.error || !issue ? (
<View className="flex-1 items-center justify-center px-6 gap-3">
<Text className="text-sm text-destructive text-center">
Failed to load issue:{" "}
{detail.error instanceof Error
? detail.error.message
: "not found"}
</Text>
<Button variant="outline" onPress={() => detail.refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : (
<View className="flex-1">
<TimelineList
issue={issue}
entries={timeline.data}
timelineLoading={timeline.isLoading}
refreshing={detail.isRefetching || timeline.isRefetching}
onRefresh={onRefresh}
highlightCommentId={highlight}
highlightNonce={h}
/>
<InlineCommentComposer issueId={id} />
</View>
)}
</View>
);
}
function confirmDelete(issue: Issue, onConfirm: () => void) {
Alert.alert(
"Delete issue?",
`${issue.identifier} and its comments, reactions, and attachments will be permanently deleted. This cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{ text: "Delete", style: "destructive", onPress: onConfirm },
],
);
}

View File

@@ -0,0 +1,114 @@
/**
* Full emoji picker for a comment reaction — opened from the per-comment
* long-press menu's "+" tapback button. Mirrors web's emoji-mart picker
* that sits behind QuickEmojiPicker's overflow button: same product
* semantics (mobile must offer the full emoji set, not only the 8 quick
* picks).
*
* Reads the comment from the timeline cache to detect an already-applied
* reaction by the current user, then fires `useToggleCommentReaction` with
* the right `existing` value so re-tapping an active emoji removes it
* (matches web behaviour and the inline ReactionBar toggle semantics).
*
* Library: `rn-emoji-keyboard` (TheWidlarzGroup/rn-emoji-keyboard). We
* embed the `EmojiKeyboard` component (no built-in modal) inside the
* Expo Router formSheet route body, so the iOS UISheetPresentationController
* still owns the chrome (grabber, detents, drag-to-dismiss).
*/
import { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { EmojiKeyboard, type EmojiType } from "rn-emoji-keyboard";
import type { Reaction } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { issueTimelineOptions } from "@/data/queries/issues";
import { useToggleCommentReaction } from "@/data/mutations/issues";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
export default function CommentEmojiPickerRoute() {
const { id, commentId } = useLocalSearchParams<{
id: string;
commentId: string;
}>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const userId = useAuthStore((s) => s.user?.id);
const toggle = useToggleCommentReaction(id);
const { colorScheme } = useColorScheme();
const { data: timeline = [] } = useQuery(issueTimelineOptions(wsId, id));
const entry = useMemo(
() => timeline.find((e) => e.id === commentId) ?? null,
[timeline, commentId],
);
const reactions = useMemo<Reaction[]>(
() => (entry?.reactions ?? []) as Reaction[],
[entry?.reactions],
);
const onSelect = useCallback(
(picked: EmojiType) => {
const existing = reactions.find(
(r) =>
r.emoji === picked.emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
toggle.mutate({ commentId, emoji: picked.emoji, existing });
router.back();
},
[reactions, userId, toggle, commentId],
);
const theme = THEME[colorScheme];
return (
<View className="flex-1">
<View className="px-4 pt-3 pb-2">
<Text className="text-lg font-semibold text-foreground">
Add Reaction
</Text>
</View>
<View className="flex-1">
<EmojiKeyboard
onEmojiSelected={onSelect}
enableSearchBar
enableRecentlyUsed
categoryPosition="top"
theme={{
backdrop: theme.background,
knob: theme.mutedForeground,
container: theme.popover,
header: theme.foreground,
skinTonesContainer: theme.secondary,
category: {
icon: theme.mutedForeground,
iconActive: theme.foreground,
container: theme.popover,
containerActive: theme.secondary,
},
search: {
background: theme.secondary,
text: theme.foreground,
placeholder: theme.mutedForeground,
icon: theme.mutedForeground,
},
customButton: {
icon: theme.mutedForeground,
iconPressed: theme.foreground,
background: theme.secondary,
backgroundPressed: theme.muted,
},
emoji: {
selected: theme.secondary,
},
}}
/>
</View>
</View>
);
}

View File

@@ -0,0 +1,203 @@
/**
* Edit issue title / description. Modal presentation, configured in
* `[workspace]/_layout.tsx`. Save runs the optimistic `useUpdateIssue`
* mutation; modal dismisses on success.
*
* Mirrors `project/[id]/edit.tsx` so users get the same gesture on both
* record types (cancel/save in header, dirty Alert on dismiss-while-dirty).
*
* Description uses `useMentionInput` + `<DescriptionField>` so the @-mention
* pipeline matches `new-issue.tsx`. v1 note: existing mentions in the
* server-side description render as raw markdown text while editing because
* there's no markdown-to-marker deserializer yet — `serialize()` still
* produces a valid round-trip since unparsed `[@name](mention://...)` literals
* pass through unchanged. New @-mentions added during the edit get serialized
* normally via the marker pipeline.
*
* Properties (status / priority / assignee / labels / project / due_date)
* are NOT edited here — they have dedicated chip pickers on the detail page.
* This screen only owns the two free-text fields.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
TextInput,
View,
} from "react-native";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { DescriptionField } from "@/components/issue/description-field";
import { MentionSuggestionBar } from "@/components/issue/mention-suggestion-bar";
import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useMentionInput } from "@/lib/use-mention-input";
export default function EditIssue() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const detail = useQuery(issueDetailOptions(wsId, id));
const update = useUpdateIssue(id);
const [title, setTitle] = useState("");
const description = useMentionInput();
const [seeded, setSeeded] = useState(false);
// `useMentionInput` returns `setText` from `useState`, which is a stable
// identity across renders. Pulling it out of the hook return lets us list
// it explicitly in the seeding effect's dep array without the whole
// `description` object (which changes every render) re-triggering the
// seed and overwriting in-progress edits.
const setDescriptionText = description.setText;
useEffect(() => {
if (!detail.data || seeded) return;
setTitle(detail.data.title);
setDescriptionText(detail.data.description ?? "");
setSeeded(true);
}, [detail.data, seeded, setDescriptionText]);
const initialDescription = detail.data?.description ?? "";
const currentDescription = description.serialize();
const dirty = useMemo(() => {
if (!detail.data || !seeded) return false;
return (
title.trim() !== detail.data.title ||
currentDescription.trim() !== initialDescription
);
}, [detail.data, seeded, title, currentDescription, initialDescription]);
const canSave =
seeded && title.trim().length > 0 && dirty && !update.isPending;
const onCancel = useCallback(() => {
if (!dirty) {
router.back();
return;
}
Alert.alert(
"Discard changes?",
"Your edits to this issue will be lost.",
[
{ text: "Keep editing", style: "cancel" },
{
text: "Discard",
style: "destructive",
onPress: () => router.back(),
},
],
);
}, [dirty]);
const onSave = useCallback(() => {
if (!canSave) return;
// `UpdateIssueRequest.description` is `string | undefined` — server
// treats empty string as "clear the description", which is what we
// want when the user wipes the field.
const patch = {
title: title.trim(),
description: currentDescription.trim(),
};
update.mutate(patch, {
onSuccess: () => router.back(),
onError: (err) => {
Alert.alert(
"Failed to save",
err instanceof Error ? err.message : "Unknown error",
);
},
});
}, [canSave, title, currentDescription, update]);
const headerLeft = useCallback(
() => (
<Pressable onPress={onCancel} className="px-1 py-1">
<Text className="text-base text-brand">Cancel</Text>
</Pressable>
),
[onCancel],
);
const headerRight = useCallback(
() => (
<Pressable
onPress={onSave}
disabled={!canSave}
className={canSave ? "px-1 py-1" : "px-1 py-1 opacity-40"}
>
<Text className="text-base text-brand font-semibold">
{update.isPending ? "Saving…" : "Save"}
</Text>
</Pressable>
),
[canSave, onSave, update.isPending],
);
return (
<>
<Stack.Screen options={{ headerLeft, headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
{!detail.data ? (
<Text className="text-sm text-muted-foreground">Loading</Text>
) : (
<>
<Field label="Title">
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Issue title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
returnKeyType="next"
editable={!update.isPending}
/>
</Field>
<Field label="Description">
<DescriptionField
description={description}
disabled={update.isPending}
/>
</Field>
</>
)}
</ScrollView>
{/* Mention suggestion bar floats above the keyboard while the user
is mid-@. Outside the ScrollView so it doesn't scroll with the
form body. */}
<MentionSuggestionBar {...description.suggestionBar} />
</KeyboardAvoidingView>
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</Text>
{children}
</View>
);
}

View File

@@ -0,0 +1,44 @@
/**
* Assignee picker route for an existing issue. Uses the native iOS Stack
* header + UISearchController (registered in ../_layout.tsx with
* `headerShown: true` + title); the search bar wiring is encapsulated in
* `useNativeSearchBar`.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueAssigneePickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
const query = useNativeSearchBar("Search people", { autoFocus: true });
const value =
issue?.assignee_type && issue?.assignee_id
? { type: issue.assignee_type, id: issue.assignee_id }
: null;
return (
<AssigneePickerBody
value={value}
query={query}
onChange={(next) => {
if (next === null) {
updateIssue.mutate({ assignee_type: null, assignee_id: null });
} else {
updateIssue.mutate({
assignee_type: next.type,
assignee_id: next.id,
});
}
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,84 @@
/**
* Due-date picker route for an existing issue.
*
* Diverges from the other single-select pickers because the native
* UIDatePicker needs a confirmation step — the user spins to a date but
* doesn't auto-commit on every onChange. Done / Clear buttons live in a
* mini header row inside the route body (the parent Stack hides its own
* header per the formSheet config), and on submit we fire the mutation +
* router.back().
*/
import { useRef } from "react";
import { Pressable, View } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import {
DueDatePickerBody,
type DueDatePickerBodyHandle,
} from "@/components/issue/pickers/due-date-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssueDueDatePickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
const ref = useRef<DueDatePickerBodyHandle>(null);
const value = issue?.due_date ?? null;
return (
<View className="flex-1">
<DueDateHeader
hasValue={!!value}
onDone={() => {
const iso = ref.current?.getIso();
if (iso) updateIssue.mutate({ due_date: iso });
router.back();
}}
onClear={() => {
updateIssue.mutate({ due_date: null });
router.back();
}}
/>
<DueDatePickerBody ref={ref} value={value} />
</View>
);
}
function DueDateHeader({
hasValue,
onDone,
onClear,
}: {
hasValue: boolean;
onDone: () => void;
onClear: () => void;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-4 pb-2">
<Text className="text-base font-semibold text-foreground">Due date</Text>
<View className="flex-row items-center gap-1">
{hasValue ? (
<Pressable
onPress={onClear}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm text-destructive">Clear</Text>
</Pressable>
) : null}
<Pressable
onPress={onDone}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm font-medium text-primary">Done</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Label picker route for an existing issue — multi-select with inline
* create. Uses native iOS Stack header + UISearchController via
* `useNativeSearchBar` (sheet stays open across toggles; the user
* dismisses via the sheet grabber or the Back button).
*/
import { useRef } from "react";
import { useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { LabelPickerBody } from "@/components/issue/pickers/label-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import {
useAttachLabel,
useDetachLabel,
} from "@/data/mutations/issues";
import { useCreateLabel } from "@/data/mutations/labels";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueLabelPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const attachLabel = useAttachLabel(id);
const detachLabel = useDetachLabel(id);
const createLabel = useCreateLabel();
const query = useNativeSearchBar("Search labels", { autoFocus: true });
// Synchronous lock to prevent double-submit on rapid taps on the Create
// row before React state updates — mirrors web's `creatingRef` pattern in
// `packages/views/issues/components/pickers/label-picker.tsx`.
const creatingRef = useRef(false);
const attached = issue?.labels ?? [];
return (
<LabelPickerBody
attached={attached}
query={query}
onAttach={(label) => attachLabel.mutate({ label })}
onDetach={(labelId) => detachLabel.mutate({ labelId })}
onCreate={(name, color) => {
if (creatingRef.current) return;
creatingRef.current = true;
createLabel.mutate(
{ name, color },
{
onSuccess: (label) => {
attachLabel.mutate({ label });
},
onSettled: () => {
creatingRef.current = false;
},
},
);
}}
/>
);
}

View File

@@ -0,0 +1,27 @@
/**
* Priority picker route for an existing issue. See ./status.tsx for the
* self-contained-route rationale.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { PriorityPickerBody } from "@/components/issue/pickers/priority-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssuePriorityPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
return (
<PriorityPickerBody
value={issue?.priority ?? "none"}
onChange={(next) => {
updateIssue.mutate({ priority: next });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,39 @@
/**
* Project picker route for an existing issue. Uses native iOS Stack header
* + UISearchController via `useNativeSearchBar` (search bar registered in
* ../_layout.tsx).
*/
import { useMemo } from "react";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { ProjectPickerBody } from "@/components/issue/pickers/project-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { findProject, projectListOptions } from "@/data/queries/projects";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueProjectPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const updateIssue = useUpdateIssue(id);
const query = useNativeSearchBar("Search projects", { autoFocus: true });
const project = useMemo(
() => findProject(projects, issue?.project_id ?? null),
[projects, issue?.project_id],
);
return (
<ProjectPickerBody
value={project ?? null}
query={query}
onChange={(next) => {
updateIssue.mutate({ project_id: next?.id ?? null });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,36 @@
/**
* Status picker route for an existing issue — presented as a formSheet
* (UISheetPresentationController) by the parent Stack.
*
* Self-contained: reads the issue from the TanStack Query detail cache,
* calls `useUpdateIssue` directly on selection, then `router.back()`s. No
* onChange callback to a parent.
*
* If the cache is cold (rare — the user reaches this screen by tapping
* a chip on the issue-detail page that already populated it), the picker
* still renders against the current value of `todo` and the optimistic
* mutation patches the cache when the user picks.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { StatusPickerBody } from "@/components/issue/pickers/status-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssueStatusPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
return (
<StatusPickerBody
value={issue?.status ?? "todo"}
onChange={(next) => {
updateIssue.mutate({ status: next });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,110 @@
/**
* Agent Runs sheet — presented as a formSheet by the parent Stack. Two
* sections: Active (queued/dispatched/running, created_at desc) and Past
* (failed → cancelled → completed, completed_at desc within each). Empty
* sections hide entirely.
*
* Both entry points (the in-card AgentActivityRow and the Stack-header
* AgentHeaderBadge) now `router.push("/[workspace]/issue/[id]/runs")` —
* the legacy `useRunsSheetStore` is gone since the route system is the
* single source of truth for what's open.
*
* Past-row tap is a no-op in v1 — transcript drilldown is deferred.
*/
import { useMemo } from "react";
import { ScrollView, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { AgentTask } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { RunRow } from "@/components/issue/run-row";
import {
issueActiveTasksOptions,
issueTasksOptions,
} from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
const PAST_STATUS_ORDER: Record<AgentTask["status"], number> = {
failed: 0,
cancelled: 1,
completed: 2,
queued: 99,
dispatched: 99,
running: 99,
};
export default function IssueRunsRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: activeTasks = [] } = useQuery(
issueActiveTasksOptions(wsId, id),
);
const { data: allTasks = [] } = useQuery(issueTasksOptions(wsId, id));
const active = useMemo(
() =>
[...activeTasks].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
[activeTasks],
);
const past = useMemo(() => {
const filtered = allTasks.filter(
(t) =>
t.status === "completed" ||
t.status === "failed" ||
t.status === "cancelled",
);
return filtered.sort((a, b) => {
const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
if (ord !== 0) return ord;
return (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
});
}, [allTasks]);
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">
Agent Runs
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<View className="px-4 gap-3 pb-4">
{active.length > 0 ? (
<Section title="Active">
{active.map((task) => (
<RunRow key={task.id} task={task} issueId={id} />
))}
</Section>
) : null}
{past.length > 0 ? (
<Section title="Past">
{past.map((task) => (
<RunRow key={task.id} task={task} issueId={id} />
))}
</Section>
) : null}
</View>
</ScrollView>
</View>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1">
<Text className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{title}
</Text>
<View>{children}</View>
</View>
);
}

View File

@@ -0,0 +1,173 @@
/**
* Status + Priority filter sheet — presented as a formSheet by the parent
* Stack. Shared by My Issues and the workspace-wide Issues page; which
* view-store to read/write is selected by the `scope` URL param.
*
* Routes that open this sheet:
* - /[workspace]/issues-filter?scope=my → useMyIssuesViewStore
* - /[workspace]/issues-filter?scope=all → useIssuesViewStore
*
* Self-contained: reads/writes the store directly, no callback passing.
*/
import { Pressable, ScrollView, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import type { IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { useIssuesViewStore } from "@/data/stores/issues-view-store";
import { useMyIssuesViewStore } from "@/data/stores/my-issues-view-store";
import { BOARD_STATUSES, STATUS_LABEL } from "@/lib/issue-status";
import { cn } from "@/lib/utils";
const ALL_STATUSES: IssueStatus[] = [...BOARD_STATUSES, "cancelled"];
// Mirrors PRIORITY_ORDER in packages/core/issues/config/priority.ts.
const PRIORITY_ORDER: IssuePriority[] = [
"urgent",
"high",
"medium",
"low",
"none",
];
// Label map duplicated across several mobile files — out of scope to
// consolidate per the SheetShell migration plan.
const PRIORITY_LABEL: Record<IssuePriority, string> = {
urgent: "Urgent",
high: "High",
medium: "Medium",
low: "Low",
none: "No priority",
};
type Scope = "my" | "all";
export default function IssuesFilterRoute() {
const { scope } = useLocalSearchParams<{ scope?: string }>();
const resolvedScope: Scope = scope === "all" ? "all" : "my";
const statusFilters = useScopedFilters(resolvedScope, "status");
const priorityFilters = useScopedFilters(resolvedScope, "priority");
const onToggleStatus = (s: IssueStatus) => {
if (resolvedScope === "all") {
useIssuesViewStore.getState().toggleStatusFilter(s);
} else {
useMyIssuesViewStore.getState().toggleStatusFilter(s);
}
};
const onTogglePriority = (p: IssuePriority) => {
if (resolvedScope === "all") {
useIssuesViewStore.getState().togglePriorityFilter(p);
} else {
useMyIssuesViewStore.getState().togglePriorityFilter(p);
}
};
const onClearFilters = () => {
if (resolvedScope === "all") {
useIssuesViewStore.getState().clearFilters();
} else {
useMyIssuesViewStore.getState().clearFilters();
}
};
const hasActive = statusFilters.length > 0 || priorityFilters.length > 0;
return (
<View className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">Filter</Text>
{hasActive ? (
<Pressable
onPress={onClearFilters}
hitSlop={8}
className="px-2 py-1 active:opacity-60"
>
<Text className="text-sm text-primary font-medium">Reset</Text>
</Pressable>
) : null}
</View>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<SectionLabel>Status</SectionLabel>
{ALL_STATUSES.map((status) => {
const checked = statusFilters.includes(status);
return (
<Pressable
key={status}
onPress={() => onToggleStatus(status)}
className={cn(
"flex-row items-center gap-3 px-4 py-2.5 active:bg-secondary",
checked && "bg-secondary/60",
)}
>
<StatusIcon status={status} size={16} />
<Text className="flex-1 text-sm text-foreground">
{STATUS_LABEL[status]}
</Text>
<CheckMark checked={checked} />
</Pressable>
);
})}
<SectionLabel>Priority</SectionLabel>
{PRIORITY_ORDER.map((priority) => {
const checked = priorityFilters.includes(priority);
return (
<Pressable
key={priority}
onPress={() => onTogglePriority(priority)}
className={cn(
"flex-row items-center gap-3 px-4 py-2.5 active:bg-secondary",
checked && "bg-secondary/60",
)}
>
<PriorityIcon priority={priority} />
<Text className="flex-1 text-sm text-foreground">
{PRIORITY_LABEL[priority]}
</Text>
<CheckMark checked={checked} />
</Pressable>
);
})}
</ScrollView>
</View>
);
}
function useScopedFilters(
scope: Scope,
kind: "status",
): IssueStatus[];
function useScopedFilters(
scope: Scope,
kind: "priority",
): IssuePriority[];
function useScopedFilters(
scope: Scope,
kind: "status" | "priority",
): IssueStatus[] | IssuePriority[] {
const allStatus = useIssuesViewStore((s) => s.statusFilters);
const allPriority = useIssuesViewStore((s) => s.priorityFilters);
const myStatus = useMyIssuesViewStore((s) => s.statusFilters);
const myPriority = useMyIssuesViewStore((s) => s.priorityFilters);
if (scope === "all") {
return kind === "status" ? allStatus : allPriority;
}
return kind === "status" ? myStatus : myPriority;
}
function SectionLabel({ children }: { children: string }) {
return (
<View className="px-4 pt-3 pb-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{children}
</Text>
</View>
);
}
function CheckMark({ checked }: { checked: boolean }) {
if (!checked) return null;
return <Text className="text-sm text-primary font-semibold"></Text>;
}

View File

@@ -0,0 +1,32 @@
/**
* Workspace-level mention picker route — formSheet, opened from any
* composer that has an `@` button (currently the issue-comment composer
* and the chat composer).
*
* `?mode=` controls which sections render:
* - "comment" (default) — @all + People + Agents + Squads + Issues.
* The comment composer offers the full surface; mentions notify the
* mentioned actor.
* - "chat" — Issues only. Chat is user ↔ single agent, so member /
* agent / squad / @all mentions are noise (and would generate
* unintended notifications). Issues remain useful as "reference this
* ticket for the agent's context".
*
* Lives at workspace level (not nested under issue/[id]) because the chat
* tab has no per-session route to nest under; making it workspace-level
* keeps a single route file serving both contexts.
*/
import { useLocalSearchParams } from "expo-router";
import { MentionPickerBody } from "@/components/issue/pickers/mention-picker-body";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
type Mode = "comment" | "chat";
export default function MentionPickerRoute() {
const { mode: rawMode } = useLocalSearchParams<{ mode?: string }>();
const mode: Mode = rawMode === "chat" ? "chat" : "comment";
const placeholder =
mode === "chat" ? "Reference an issue" : "Search people or issues";
const query = useNativeSearchBar(placeholder, { autoFocus: true });
return <MentionPickerBody mode={mode} query={query} />;
}

View File

@@ -0,0 +1,12 @@
import { View } from "react-native";
import { Text } from "@/components/ui/text";
export default function AgentsPage() {
return (
<View className="flex-1 items-center justify-center bg-background px-6">
<Text className="text-sm text-muted-foreground text-center">
Agents coming soon.
</Text>
</View>
);
}

View File

@@ -0,0 +1,383 @@
/**
* Workspace-wide Issues page. Mirrors web `packages/views/issues/components/
* issues-page.tsx:32-94`: fetch every issue in the workspace, expose
* `all / members / agents` scope tabs, group by status, allow status +
* priority filtering.
*
* Scope is a **client-side** filter on `assignee_type` — matches web
* `issues-page.tsx:90-94`. This keeps `issueListOptions(wsId)` workspace-
* scoped (no scope param on the wire), so `issueKeys.list(wsId)` and
* `useIssuesRealtime` need no changes.
*
* Differences vs My Issues (`(tabs)/my-issues.tsx`):
* - Workspace-wide list (all issues), not user-scoped.
* - Three scopes are `all / members / agents` (assignee_type pre-filter),
* not `assigned / created / agents` (per-user predicates).
* - Independent filter store (`useIssuesViewStore`) so workspace-level
* filters don't bleed into the per-user view.
*
* Filters beyond status/priority (assignee / project / label / creator)
* are deferred — power-user features with non-trivial picker cost; ship
* after the parity-critical scope tabs land.
*/
import { useMemo } from "react";
import { Pressable, SectionList, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { Issue, IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
// Header chrome (back + "Issues" title) comes from the parent Stack
// (`apps/mobile/app/(app)/[workspace]/_layout.tsx:269`). The Filter
// affordance now lives in <ScopeToolbar> below, matching web's
// IssuesHeader pattern (scope + filter share a row).
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
import { IssuesLoading } from "@/components/issue/issues-loading";
import { issueListOptions } from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import {
useIssuesViewStore,
type IssuesScope,
} from "@/data/stores/issues-view-store";
import { useClearFiltersOnWorkspaceChange } from "@/lib/use-clear-filters-on-workspace-change";
import {
BOARD_STATUSES,
PRIORITY_LABEL,
STATUS_LABEL,
} from "@/lib/issue-status";
import { filterIssues } from "@/lib/filter-issues";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
type IssueSection = { status: IssueStatus; data: Issue[] };
// Scope tab definitions. Mirrors web `issuesScopeStore`. Counts are NOT
// rendered on the pill labels — web's `IssuesHeader` doesn't show them
// either, and on SE3 (375pt) "(123)" appended to each label pushes the
// row past the safe width when filter icon shares the row. Per-status
// counts still appear on the SectionList headers below.
const SCOPES: { value: IssuesScope; label: string }[] = [
{ value: "all", label: "All" },
{ value: "members", label: "Members" },
{ value: "agents", label: "Agents" },
];
export default function IssuesPage() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const scope = useIssuesViewStore((s) => s.scope);
const setScope = useIssuesViewStore((s) => s.setScope);
const statusFilters = useIssuesViewStore((s) => s.statusFilters);
const priorityFilters = useIssuesViewStore((s) => s.priorityFilters);
const openFilter = () => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/issues-filter",
params: { workspace: wsSlug, scope: "all" },
});
};
useClearFiltersOnWorkspaceChange(
useIssuesViewStore.getState().clearFilters,
wsId,
);
const { data, isLoading, error, refetch, isRefetching } = useQuery(
issueListOptions(wsId),
);
const allIssues = data ?? [];
// Scope pre-filter — mirrors web `issues-page.tsx:90-94`. Applied before
// status/priority filtering so chip filters operate on the visible slice.
const scopedIssues = useMemo(() => {
if (scope === "members") {
return allIssues.filter((i) => i.assignee_type === "member");
}
if (scope === "agents") {
return allIssues.filter(
(i) => i.assignee_type === "agent" || i.assignee_type === "squad",
);
}
return allIssues;
}, [allIssues, scope]);
const filtered = useMemo(
() => filterIssues(scopedIssues, statusFilters, priorityFilters),
[scopedIssues, statusFilters, priorityFilters],
);
// Section grouping uses BOARD_STATUSES (cancelled excluded) — matches web
// `issues-page.tsx:117-125`.
const sections = useMemo<IssueSection[]>(() => {
if (filtered.length === 0) return [];
const byStatus = new Map<IssueStatus, Issue[]>();
for (const issue of filtered) {
const list = byStatus.get(issue.status);
if (list) list.push(issue);
else byStatus.set(issue.status, [issue]);
}
const visibleStatuses =
statusFilters.length > 0
? BOARD_STATUSES.filter((s) => statusFilters.includes(s))
: BOARD_STATUSES;
return visibleStatuses
.map((status) => ({ status, data: byStatus.get(status) ?? [] }))
.filter((s) => s.data.length > 0);
}, [filtered, statusFilters]);
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 0;
const showEmptyState = !isLoading && !error && filtered.length === 0;
return (
<View className="flex-1 bg-background">
<ScopeToolbar
scopes={SCOPES}
scope={scope}
onChange={(v) => setScope(v)}
onOpenFilter={openFilter}
hasActiveFilters={hasActiveFilters}
/>
{hasActiveFilters ? (
<ActiveFilterChips
statusFilters={statusFilters}
priorityFilters={priorityFilters}
onClearStatus={(s) =>
useIssuesViewStore.getState().toggleStatusFilter(s)
}
onClearPriority={(p) =>
useIssuesViewStore.getState().togglePriorityFilter(p)
}
/>
) : null}
{isLoading ? (
<IssuesLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : showEmptyState ? (
<EmptyState
message={
hasActiveFilters
? "No issues match the current filters."
: emptyMessageForScope(scope)
}
/>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled={false}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
renderSectionHeader={({ section }) => (
<SectionHeader status={section.status} count={section.data.length} />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<IssueRow
issue={item}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/issue/${item.id}`);
}}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
/**
* Outline icon button matching the pill height. Identical to the helper in
* `(tabs)/my-issues.tsx` for the same reason ScopeToolbar is duplicated:
* two callers don't justify a shared primitive yet.
*/
function FilterButton({
onPress,
hasActiveFilters,
}: {
onPress: () => void;
hasActiveFilters: boolean;
}) {
const { colorScheme } = useColorScheme();
return (
<View style={{ position: "relative" }} className="ml-2">
<Button
variant="outline"
size="sm"
onPress={onPress}
accessibilityLabel="Filter"
className="w-9 px-0"
>
<Ionicons
name="options-outline"
size={16}
color={THEME[colorScheme].mutedForeground}
/>
</Button>
{hasActiveFilters ? (
<View
pointerEvents="none"
className="absolute top-1 right-1 size-1.5 rounded-full bg-brand"
/>
) : null}
</View>
);
}
/**
* Toolbar row mirroring web `IssuesHeader`
* (`packages/views/issues/components/issues-header.tsx:516-543`): left-aligned
* scope pill group + right-side Filter icon (red dot on active filters).
* Identical to the equivalent in `(tabs)/my-issues.tsx` — kept duplicated
* because the threshold for a shared `components/ui/` primitive is 3 callers,
* and two callers don't justify the abstraction yet.
*/
function ScopeToolbar<S extends string>({
scopes,
scope,
onChange,
onOpenFilter,
hasActiveFilters,
}: {
scopes: { value: S; label: string }[];
scope: S;
onChange: (value: S) => void;
onOpenFilter: () => void;
hasActiveFilters: boolean;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-2 pb-2">
<View className="flex-row items-center gap-1 flex-shrink min-w-0">
{scopes.map((s) => {
const active = scope === s.value;
return (
<Button
key={s.value}
variant="outline"
size="sm"
onPress={() => onChange(s.value)}
className={active ? "bg-accent" : ""}
accessibilityState={{ selected: active }}
>
<Text
numberOfLines={1}
className={active ? "text-accent-foreground" : "text-muted-foreground"}
>
{s.label}
</Text>
</Button>
);
})}
</View>
<FilterButton
onPress={onOpenFilter}
hasActiveFilters={hasActiveFilters}
/>
</View>
);
}
function ActiveFilterChips({
statusFilters,
priorityFilters,
onClearStatus,
onClearPriority,
}: {
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
onClearStatus: (s: IssueStatus) => void;
onClearPriority: (p: IssuePriority) => void;
}) {
return (
<View className="flex-row flex-wrap gap-1.5 px-4 pb-2">
{statusFilters.map((s) => (
<Chip
key={`s-${s}`}
label={STATUS_LABEL[s]}
onClear={() => onClearStatus(s)}
/>
))}
{priorityFilters.map((p) => (
<Chip
key={`p-${p}`}
label={PRIORITY_LABEL[p]}
onClear={() => onClearPriority(p)}
/>
))}
</View>
);
}
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
const { colorScheme } = useColorScheme();
return (
<Pressable
onPress={onClear}
className="flex-row items-center gap-1 pl-2.5 pr-2 py-1 rounded-full border border-border bg-secondary/40 active:bg-secondary"
>
<Text className="text-xs text-foreground">{label}</Text>
<Ionicons
name="close"
size={12}
color={THEME[colorScheme].mutedForeground}
/>
</Pressable>
);
}
function SectionHeader({
status,
count,
}: {
status: IssueStatus;
count: number;
}) {
return (
<View className="flex-row items-center gap-2 px-4 py-2 bg-background">
<StatusIcon status={status} size={14} />
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{STATUS_LABEL[status]}
</Text>
<Text className="text-xs text-muted-foreground/60">{count}</Text>
</View>
);
}
function EmptyState({ message }: { message: string }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground text-center">
{message}
</Text>
</View>
);
}
function emptyMessageForScope(scope: IssuesScope): string {
switch (scope) {
case "all":
return "No issues in this workspace.";
case "members":
return "No issues assigned to a member.";
case "agents":
return "No issues assigned to agents or squads.";
}
}

View File

@@ -0,0 +1,235 @@
/**
* Pinned items list — mirrors the role of web's sidebar "Pinned" section
* (packages/views/layout/app-sidebar.tsx PinnedItemRow), one screen up the
* navigation tree because phones have no sidebar.
*
* Architecture invariant (matches web): `PinnedItem` only carries metadata
* (`item_type` + `item_id`). Title / status / icon are fetched per-row via
* `issueDetailOptions` / `projectDetailOptions`, so when an issue's status
* or a project's title changes via `issue:updated` / `project:updated`,
* this list updates automatically — no cross-entity invalidate on pinKeys
* is needed. Do NOT inline the display fields into the pin row; that
* couples this view to a stale snapshot. See packages/core/types/pin.ts
* top comment.
*
* Rendering split by `item_type`:
* - issue → existing `<IssueRow>` (used by my-issues / more/issues /
* project-related-issues), `showStatus` because pins are heterogeneous
* (no section grouping by status).
* - project → existing `<ProjectRow>` (used by more/projects).
*
* Missing / no-permission rows: the detail query may 404 (issue/project
* deleted, user lost access, server returned a parseWithFallback fallback
* with an empty id). We render a low-emphasis placeholder so the user can
* unpin it from here — otherwise a dead pin stays forever.
*/
import { useMemo } from "react";
import {
ActivityIndicator,
Pressable,
RefreshControl,
ScrollView,
View,
} from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { Issue, PinnedItem, Project } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { IssueRow } from "@/components/issue/issue-row";
import { ProjectRow } from "@/components/project/project-row";
import { pinListOptions } from "@/data/queries/pins";
import { useDeletePin } from "@/data/mutations/pins";
import { issueDetailOptions } from "@/data/queries/issues";
import { projectDetailOptions } from "@/data/queries/projects";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
export default function PinsPage() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const userId = useAuthStore((s) => s.user?.id ?? null);
const { data, isLoading, error, refetch, isRefetching } = useQuery(
pinListOptions(wsId, userId),
);
// Sort by `position` ascending so the order matches web's sidebar
// (the reorder endpoint writes 1-based positions there too).
const pins = useMemo(
() => [...(data ?? [])].sort((a, b) => a.position - b.position),
[data],
);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-background">
<ActivityIndicator />
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-background px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load pins:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
);
}
if (pins.length === 0) {
return (
<View className="flex-1 items-center justify-center bg-background px-6">
<Text className="text-sm text-muted-foreground text-center">
No pins yet. Pin an issue or project from its actions menu to
surface it here.
</Text>
</View>
);
}
return (
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="pb-6"
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={() => refetch()}
/>
}
showsVerticalScrollIndicator={false}
>
{pins.map((pin, idx) => (
<View key={pin.id}>
{idx > 0 ? <View className="h-px bg-border ml-4" /> : null}
<PinRow pin={pin} wsId={wsId} wsSlug={wsSlug} />
</View>
))}
</ScrollView>
);
}
function PinRow({
pin,
wsId,
wsSlug,
}: {
pin: PinnedItem;
wsId: string | null;
wsSlug: string | null;
}) {
if (pin.item_type === "issue") {
return (
<IssuePinRow pin={pin} wsId={wsId} wsSlug={wsSlug} />
);
}
return <ProjectPinRow pin={pin} wsId={wsId} wsSlug={wsSlug} />;
}
function IssuePinRow({
pin,
wsId,
wsSlug,
}: {
pin: PinnedItem;
wsId: string | null;
wsSlug: string | null;
}) {
const { data, isLoading } = useQuery(issueDetailOptions(wsId, pin.item_id));
// EMPTY_ISSUE_FALLBACK has an empty id — treat as deleted/no-access.
const issue = data && data.id ? (data as Issue) : null;
if (isLoading) return <SkeletonRow />;
if (!issue)
return <MissingPinRow itemType="issue" itemId={pin.item_id} />;
return (
<IssueRow
issue={issue}
showStatus
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/issue/${issue.id}`);
}}
/>
);
}
function ProjectPinRow({
pin,
wsId,
wsSlug,
}: {
pin: PinnedItem;
wsId: string | null;
wsSlug: string | null;
}) {
const { data, isLoading } = useQuery(
projectDetailOptions(wsId, pin.item_id),
);
const project = data && data.id ? (data as Project) : null;
if (isLoading) return <SkeletonRow />;
if (!project)
return <MissingPinRow itemType="project" itemId={pin.item_id} />;
return (
<ProjectRow
project={project}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/project/${project.id}`);
}}
/>
);
}
function SkeletonRow() {
return (
<View className="px-4 py-3 flex-row items-center gap-3">
<View className="size-5 rounded bg-muted" />
<View className="flex-1 h-4 rounded bg-muted" />
</View>
);
}
/**
* Renders for pins whose target issue/project was deleted or revoked.
* Tapping triggers unpin so the user can clean it up; no destination
* navigation since there's nothing to navigate to. Subtle styling so
* it doesn't dominate the list of live pins.
*/
function MissingPinRow({
itemType,
itemId,
}: {
itemType: "issue" | "project";
itemId: string;
}) {
const { colorScheme } = useColorScheme();
const deletePin = useDeletePin();
return (
<Pressable
onPress={() => deletePin.mutate({ itemType, itemId })}
className="px-4 py-3 flex-row items-center gap-3 active:bg-secondary opacity-60"
accessibilityLabel={`Unavailable ${itemType}, tap to unpin`}
>
<Ionicons
name="alert-circle-outline"
size={18}
color={THEME[colorScheme].mutedForeground}
/>
<Text className="flex-1 text-sm text-muted-foreground" numberOfLines={1}>
Unavailable {itemType} tap to unpin
</Text>
</Pressable>
);
}

View File

@@ -0,0 +1,126 @@
/**
* Projects browse page. Flat FlatList over the workspace's projects.
*
* Title and `+` button live in the native iOS Stack header (declared via
* Stack.Screen options in parent `_layout.tsx`, overridden here to add
* `headerRight`). Rendering an in-body title row on top of the native bar
* would stack two "Projects" labels vertically.
*
* Sort: client-side by `updated_at` desc — most recently touched at top.
* Mirrors web's default list ordering. WS `project:*` events keep the cache
* fresh via the listing-level realtime hook (`useProjectsRealtime` in
* `_layout.tsx`), so pull-to-refresh is rarely needed but kept for the
* cellular-edge case where a WS reconnect missed events.
*/
import { useCallback, useMemo } from "react";
import {
ActivityIndicator,
FlatList,
RefreshControl,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { Stack, router } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ProjectRow } from "@/components/project/project-row";
import { projectListOptions } from "@/data/queries/projects";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function ProjectsPage() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { data, isLoading, error, refetch, isRefetching } = useQuery(
projectListOptions(wsId),
);
const sorted = useMemo(() => {
if (!data) return [];
return [...data].sort(
(a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
);
}, [data]);
const goCreate = useCallback(() => {
if (wsSlug) router.push(`/${wsSlug}/project/new`);
}, [wsSlug]);
const headerRight = useCallback(() => {
return <PlusButton onPress={goCreate} />;
}, [goCreate]);
return (
<SafeAreaView className="flex-1 bg-background" edges={[]}>
<Stack.Screen options={{ headerRight }} />
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load projects:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : sorted.length === 0 ? (
<EmptyState onCreate={goCreate} />
) : (
<FlatList
data={sorted}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
renderItem={({ item }) => (
<ProjectRow
project={item}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/project/${item.id}`);
}}
/>
)}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} />
}
contentContainerClassName="pb-6"
/>
)}
</SafeAreaView>
);
}
function PlusButton({ onPress }: { onPress: () => void }) {
return (
<IconButton
name="add"
onPress={onPress}
accessibilityLabel="New project"
/>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<View className="flex-1 items-center justify-center px-6 gap-4">
<Text className="text-base font-medium text-foreground">
No projects yet
</Text>
<Text className="text-sm text-muted-foreground text-center">
Group related issues into a project to track progress and assign a
lead.
</Text>
<Button variant="default" onPress={onCreate}>
<Text>Create project</Text>
</Button>
</View>
);
}

View File

@@ -0,0 +1,279 @@
/**
* Settings page — account info, workspace switching, appearance, profile and
* notifications subscreens, and sign out.
*
* Inherits the responsibilities the old More tab carried (account row,
* workspace list, sign-out button) now that the More tab is gone and global
* navigation lives in GlobalNavMenu.
*
* Subscreens push under more/settings/:
* - more/settings/profile — edit name + avatar
* - more/settings/notifications — per-group inbox + system toggles
*
* Theme picker stays inline (3 fixed options, fits in one section).
*/
import { Alert, ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { Workspace } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import {
useColorScheme,
type ThemePreference,
} from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { cn } from "@/lib/utils";
const THEME_OPTIONS: Array<{ value: ThemePreference; label: string }> = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
function initialsOf(name: string | undefined): string {
if (!name) return "?";
return name
.split(" ")
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
export default function SettingsPage() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const currentSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const clearWorkspace = useWorkspaceStore((s) => s.clear);
const { data, isLoading, error } = useQuery(workspaceListOptions());
const { preference, setPreference, colorScheme } = useColorScheme();
const mutedFg = THEME[colorScheme].mutedForeground;
const onSwitch = async (ws: Workspace) => {
if (ws.slug === currentSlug) return;
await setCurrentWorkspace(ws.id, ws.slug);
router.replace(`/${ws.slug}/inbox`);
};
const onSignOut = () => {
Alert.alert(
"Sign out",
"You'll need to sign in again to use Multica on this device.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Sign out",
style: "destructive",
onPress: async () => {
await clearWorkspace();
await logout();
},
},
],
);
};
const goProfile = () => router.push(`/${currentSlug}/more/settings/profile`);
const goNotifications = () =>
router.push(`/${currentSlug}/more/settings/notifications`);
return (
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="px-4 py-4 gap-6"
>
<SectionGroup title="Account">
<NavRow
onPress={goProfile}
chevronColor={mutedFg}
leading={
<Avatar alt={user?.name ?? "User avatar"} className="size-10">
{user?.avatar_url ? (
<AvatarImage source={{ uri: user.avatar_url }} />
) : null}
<AvatarFallback>
<Text className="text-sm font-semibold text-muted-foreground">
{initialsOf(user?.name)}
</Text>
</AvatarFallback>
</Avatar>
}
title={user?.name ?? "—"}
subtitle={user?.email}
/>
<Separator />
<NavRow
onPress={goNotifications}
chevronColor={mutedFg}
title="Notifications"
subtitle="Inbox and system alerts"
/>
</SectionGroup>
<SectionGroup title="Workspaces">
{isLoading ? (
<View className="py-4 items-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="p-4">
<Text className="text-sm text-destructive">
Failed to load workspaces
</Text>
</View>
) : (
data?.map((ws, idx) => {
const isActive = ws.slug === currentSlug;
const isLast = idx === (data?.length ?? 0) - 1;
return (
<View key={ws.id}>
<WorkspaceRow
name={ws.name}
slug={ws.slug}
isActive={isActive}
iconColor={mutedFg}
onPress={() => onSwitch(ws)}
/>
{!isLast ? <Separator /> : null}
</View>
);
})
)}
</SectionGroup>
<SectionGroup title="Appearance">
{/* Two converging entry points by design, NOT a double-fire:
- Tap on small radio circle → RadioGroupItem (Pressable, inner) consumes → onValueChange fires
- Tap on text / row padding → outer Pressable.onPress fires
RN's responder system gives inner Pressable priority, so each tap
triggers exactly one setPreference. Both paths land at the same
handler intentionally — the Pressable wrapper exists only to
extend the tap target to the full row (iOS standard). */}
<RadioGroup
value={preference}
onValueChange={(v) => setPreference(v as ThemePreference)}
className="gap-0"
>
{THEME_OPTIONS.map((opt, idx) => {
const isLast = idx === THEME_OPTIONS.length - 1;
return (
<View key={opt.value}>
<Pressable
onPress={() => setPreference(opt.value)}
className="flex-row items-center px-4 py-3.5 active:bg-secondary gap-3"
>
<RadioGroupItem value={opt.value} />
<Text className="flex-1 text-base font-medium text-foreground">
{opt.label}
</Text>
</Pressable>
{!isLast ? <Separator /> : null}
</View>
);
})}
</RadioGroup>
</SectionGroup>
<View className="pt-2">
<Button variant="destructive" onPress={onSignOut}>
<Text>Sign out</Text>
</Button>
</View>
</ScrollView>
);
}
function NavRow({
onPress,
leading,
title,
subtitle,
chevronColor,
}: {
onPress: () => void;
leading?: React.ReactNode;
title: string;
subtitle?: string;
chevronColor: string;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
"flex-row items-center px-4 py-3.5 active:bg-secondary gap-3",
)}
>
{leading}
<View className="flex-1">
<Text className="text-base font-medium text-foreground">{title}</Text>
{subtitle ? (
<Text className="text-sm text-muted-foreground mt-0.5">
{subtitle}
</Text>
) : null}
</View>
<Ionicons name="chevron-forward" size={18} color={chevronColor} />
</Pressable>
);
}
function SectionGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="gap-2">
<Text className="text-xs uppercase tracking-wider text-muted-foreground px-1">
{title}
</Text>
<View className="rounded-md border border-border bg-card overflow-hidden">
{children}
</View>
</View>
);
}
function WorkspaceRow({
name,
slug,
isActive,
iconColor,
onPress,
}: {
name: string;
slug: string;
isActive: boolean;
iconColor: string;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
disabled={isActive}
className="flex-row items-center px-4 py-3.5 active:bg-secondary"
>
<View className="flex-1">
<Text className="text-base font-medium text-foreground">{name}</Text>
<Text className="text-xs text-muted-foreground mt-0.5">/{slug}</Text>
</View>
<Ionicons
name={isActive ? "checkmark" : "chevron-forward"}
size={18}
color={iconColor}
/>
</Pressable>
);
}

View File

@@ -0,0 +1,180 @@
/**
* Notification preferences subscreen. 5 inbox groups + system_notifications
* toggle, each backed by an optimistic PUT /api/notification-preferences.
*
* Copy mirrors packages/views/settings/components/notifications-tab.tsx but
* hardcoded English (mobile has no i18n infra yet). The group labels MUST
* stay in sync with web — they describe the same server-side semantics,
* and divergent labels would violate behavioral parity (apps/mobile/CLAUDE.md).
*/
import { ActivityIndicator, ScrollView, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import type {
NotificationGroupKey,
NotificationPreferences,
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { useWorkspaceStore } from "@/data/workspace-store";
import { notificationPreferenceOptions } from "@/data/queries/notification-preferences";
import { useUpdateNotificationPreferences } from "@/data/mutations/notification-preferences";
const INBOX_GROUPS: Array<{
key: Exclude<NotificationGroupKey, "system_notifications">;
label: string;
description: string;
}> = [
{
key: "assignments",
label: "Assignments",
description: "When you're assigned an issue or removed as assignee.",
},
{
key: "status_changes",
label: "Status changes",
description: "When an issue's status changes.",
},
{
key: "comments",
label: "Comments",
description: "New comments on issues you're subscribed to.",
},
{
key: "updates",
label: "Issue updates",
description: "Edits to title, description, labels, priority, or due date.",
},
{
key: "agent_activity",
label: "Agent activity",
description: "When an agent picks up, runs, or completes a task.",
},
];
export default function NotificationsSettingsScreen() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data, isLoading, error } = useQuery(
notificationPreferenceOptions(wsId),
);
const mutation = useUpdateNotificationPreferences();
const preferences: NotificationPreferences = data?.preferences ?? {};
const onToggle = (key: NotificationGroupKey, enabled: boolean) => {
const next: NotificationPreferences = { ...preferences };
if (enabled) {
// Default is "all" — omitting the key keeps the object clean.
delete next[key];
} else {
next[key] = "muted";
}
mutation.mutate(next);
};
const systemEnabled = preferences.system_notifications !== "muted";
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-background">
<ActivityIndicator />
</View>
);
}
if (error) {
return (
<View className="flex-1 items-center justify-center bg-background px-6">
<Text className="text-sm text-destructive text-center">
Failed to load notification preferences.
</Text>
</View>
);
}
return (
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="px-4 py-4 gap-6"
>
<Section
title="Inbox notifications"
description="Which events show up in your inbox."
>
{INBOX_GROUPS.map((group, idx) => {
const enabled = preferences[group.key] !== "muted";
const isLast = idx === INBOX_GROUPS.length - 1;
return (
<View key={group.key}>
<View className="flex-row items-center px-4 py-3 gap-3">
<View className="flex-1">
<Text className="text-base font-medium text-foreground">
{group.label}
</Text>
<Text className="text-xs text-muted-foreground mt-0.5">
{group.description}
</Text>
</View>
<Switch
checked={enabled}
onCheckedChange={(checked) => onToggle(group.key, checked)}
/>
</View>
{!isLast ? <Separator /> : null}
</View>
);
})}
</Section>
<Section
title="System"
description="Multica-wide announcements and important account events."
>
<View className="flex-row items-center px-4 py-3 gap-3">
<View className="flex-1">
<Text className="text-base font-medium text-foreground">
System notifications
</Text>
<Text className="text-xs text-muted-foreground mt-0.5">
Account changes, security alerts, product updates.
</Text>
</View>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) =>
onToggle("system_notifications", checked)
}
/>
</View>
</Section>
</ScrollView>
);
}
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<View className="gap-2">
<View className="px-1">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{title}
</Text>
{description ? (
<Text className="text-xs text-muted-foreground mt-1">
{description}
</Text>
) : null}
</View>
<View className="rounded-md border border-border bg-card overflow-hidden">
{children}
</View>
</View>
);
}

View File

@@ -0,0 +1,226 @@
/**
* Profile edit subscreen — name + avatar.
*
* Avatar tap opens an iOS native ActionSheet (Take Photo / Choose from Library
* / Remove). Mirrors the avatar upload flow in
* packages/views/settings/components/account-tab.tsx but the picker uses
* native APIs per CLAUDE.md "iOS native > RNR > discuss" waterfall.
*
* Save runs PATCH /api/me then writes the returned user back to the auth
* store via setUser — same source-of-truth pattern as web (server response
* is authoritative, never the local form state).
*/
import { useEffect, useState } from "react";
import {
ActionSheetIOS,
Alert,
ActivityIndicator,
Pressable,
ScrollView,
View,
} from "react-native";
import * as ImagePicker from "expo-image-picker";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { TextField } from "@/components/ui/text-field";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { useAuthStore } from "@/data/auth-store";
import { api } from "@/data/api";
import type { FileAsset } from "@/data/api";
const MAX_AVATAR_BYTES = 5 * 1024 * 1024; // 5 MB — matches what's reasonable on cellular.
function initialsOf(name: string | undefined): string {
if (!name) return "?";
return name
.split(" ")
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
export default function ProfileSettingsScreen() {
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [name, setName] = useState(user?.name ?? "");
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
// Resync if `user` updates from outside (avatar upload, refetch, login as
// different user). Without this the form would render stale init forever.
useEffect(() => {
setName(user?.name ?? "");
}, [user]);
const dirty = name.trim() !== (user?.name ?? "") && name.trim().length > 0;
const handleAvatarPick = () => {
const options = ["Take Photo", "Choose from Library", "Remove Photo", "Cancel"];
const removeIndex = user?.avatar_url ? 2 : -1;
const cancelIndex = user?.avatar_url ? 3 : 2;
const visibleOptions = user?.avatar_url ? options : options.filter((_, i) => i !== 2);
ActionSheetIOS.showActionSheetWithOptions(
{
options: visibleOptions,
cancelButtonIndex: cancelIndex,
destructiveButtonIndex: removeIndex >= 0 ? removeIndex : undefined,
},
async (index) => {
if (index === cancelIndex) return;
if (index === 0) await pickFromCamera();
else if (index === 1) await pickFromLibrary();
else if (index === removeIndex) await removeAvatar();
},
);
};
const pickFromCamera = async () => {
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert("Permission needed", "Camera access is required to take a photo.");
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled) await uploadAvatar(result.assets[0]);
};
const pickFromLibrary = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled) await uploadAvatar(result.assets[0]);
};
const uploadAvatar = async (asset: ImagePicker.ImagePickerAsset) => {
if (asset.fileSize && asset.fileSize > MAX_AVATAR_BYTES) {
Alert.alert("Image too large", "Pick an image under 5 MB.");
return;
}
const fileAsset: FileAsset = {
uri: asset.uri,
// expo-image-picker doesn't always supply a fileName (camera captures);
// fabricate one from the URI so the multipart upload has a stable name.
name: asset.fileName ?? `avatar-${Date.now()}.jpg`,
type: asset.mimeType ?? "image/jpeg",
};
setUploading(true);
try {
const attachment = await api.uploadFile(fileAsset);
const updated = await api.updateMe({ avatar_url: attachment.url });
setUser(updated);
} catch (err) {
Alert.alert(
"Upload failed",
err instanceof Error ? err.message : "Could not upload avatar.",
);
} finally {
setUploading(false);
}
};
const removeAvatar = async () => {
setUploading(true);
try {
const updated = await api.updateMe({ avatar_url: "" });
setUser(updated);
} catch (err) {
Alert.alert(
"Remove failed",
err instanceof Error ? err.message : "Could not remove avatar.",
);
} finally {
setUploading(false);
}
};
const handleSave = async () => {
if (!dirty) return;
setSaving(true);
try {
const updated = await api.updateMe({ name: name.trim() });
setUser(updated);
} catch (err) {
Alert.alert(
"Save failed",
err instanceof Error ? err.message : "Could not update profile.",
);
} finally {
setSaving(false);
}
};
return (
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="px-4 py-6 gap-6"
keyboardShouldPersistTaps="handled"
>
<View className="items-center gap-3">
<Pressable onPress={handleAvatarPick} disabled={uploading}>
<Avatar alt={user?.name ?? "Your avatar"} className="size-24">
{user?.avatar_url ? (
<AvatarImage source={{ uri: user.avatar_url }} />
) : null}
<AvatarFallback>
<Text className="text-2xl font-semibold text-muted-foreground">
{initialsOf(user?.name)}
</Text>
</AvatarFallback>
</Avatar>
</Pressable>
{uploading ? (
<ActivityIndicator />
) : (
<Text className="text-xs text-muted-foreground">
Tap to change photo
</Text>
)}
</View>
<Separator />
<View className="gap-4">
<View>
<Text className="text-xs text-muted-foreground mb-1.5">Name</Text>
<TextField
value={name}
onChangeText={setName}
placeholder="Your name"
autoCapitalize="words"
autoCorrect={false}
returnKeyType="done"
/>
</View>
<View>
<Text className="text-xs text-muted-foreground mb-1.5">Email</Text>
<View className="rounded-md border border-border bg-muted px-3 py-2.5">
<Text className="text-base text-muted-foreground">
{user?.email ?? "—"}
</Text>
</View>
<Text className="text-xs text-muted-foreground mt-1.5">
Email is set at sign-up and can&apos;t be changed here.
</Text>
</View>
</View>
<Button onPress={handleSave} disabled={!dirty || saving}>
<Text>{saving ? "Saving…" : "Save"}</Text>
</Button>
</ScrollView>
);
}

View File

@@ -0,0 +1,27 @@
/**
* Assignee picker route for the in-progress new-issue draft. See ./status.tsx.
* Uses the same iOS-native nav header + UISearchController pattern as
* `issue/[id]/picker/assignee.tsx`, with the search bar wiring encapsulated
* in `useNativeSearchBar`.
*/
import { router } from "expo-router";
import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-body";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function NewIssueAssigneePickerRoute() {
const assignee = useNewIssueDraftStore((s) => s.assignee);
const setAssignee = useNewIssueDraftStore((s) => s.setAssignee);
const query = useNativeSearchBar("Search people", { autoFocus: true });
return (
<AssigneePickerBody
value={assignee}
query={query}
onChange={(next) => {
setAssignee(next);
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,58 @@
/**
* Due-date picker route for the in-progress new-issue draft. See ./status.tsx.
*
* Same Done / Clear pattern as the issue-detail variant
* (`issue/[id]/picker/due-date.tsx`) — UIDatePicker doesn't auto-commit, so
* the route renders a tiny header with action buttons.
*/
import { useRef } from "react";
import { Pressable, View } from "react-native";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import {
DueDatePickerBody,
type DueDatePickerBodyHandle,
} from "@/components/issue/pickers/due-date-picker-body";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
export default function NewIssueDueDatePickerRoute() {
const dueDate = useNewIssueDraftStore((s) => s.dueDate);
const setDueDate = useNewIssueDraftStore((s) => s.setDueDate);
const ref = useRef<DueDatePickerBodyHandle>(null);
return (
<View className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-4 pb-2">
<Text className="text-base font-semibold text-foreground">
Due date
</Text>
<View className="flex-row items-center gap-1">
{dueDate ? (
<Pressable
onPress={() => {
setDueDate(null);
router.back();
}}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm text-destructive">Clear</Text>
</Pressable>
) : null}
<Pressable
onPress={() => {
const iso = ref.current?.getIso();
if (iso) setDueDate(iso);
router.back();
}}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm font-medium text-primary">Done</Text>
</Pressable>
</View>
</View>
<DueDatePickerBody ref={ref} value={dueDate} />
</View>
);
}

View File

@@ -0,0 +1,21 @@
/**
* Priority picker route for the in-progress new-issue draft. See ./status.tsx.
*/
import { router } from "expo-router";
import { PriorityPickerBody } from "@/components/issue/pickers/priority-picker-body";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
export default function NewIssuePriorityPickerRoute() {
const priority = useNewIssueDraftStore((s) => s.priority);
const setPriority = useNewIssueDraftStore((s) => s.setPriority);
return (
<PriorityPickerBody
value={priority}
onChange={(next) => {
setPriority(next);
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,26 @@
/**
* Project picker route for the in-progress new-issue draft. Uses the same
* native iOS Stack header + UISearchController pattern as
* `issue/[id]/picker/project.tsx`.
*/
import { router } from "expo-router";
import { ProjectPickerBody } from "@/components/issue/pickers/project-picker-body";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function NewIssueProjectPickerRoute() {
const project = useNewIssueDraftStore((s) => s.project);
const setProject = useNewIssueDraftStore((s) => s.setProject);
const query = useNativeSearchBar("Search projects", { autoFocus: true });
return (
<ProjectPickerBody
value={project}
query={query}
onChange={(next) => {
setProject(next);
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,23 @@
/**
* Status picker route for the in-progress new-issue draft. Reads/writes
* `useNewIssueDraftStore` — the new-issue.tsx modal owns the draft and
* reads from the same store. See ../new-issue.tsx for the lifecycle.
*/
import { router } from "expo-router";
import { StatusPickerBody } from "@/components/issue/pickers/status-picker-body";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
export default function NewIssueStatusPickerRoute() {
const status = useNewIssueDraftStore((s) => s.status);
const setStatus = useNewIssueDraftStore((s) => s.setStatus);
return (
<StatusPickerBody
value={status}
onChange={(next) => {
setStatus(next);
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,145 @@
/**
* New issue creation modal — manual only.
*
* Layout follows Apple Reminders / Linear iOS / Things 3: one vertical
* scrolling form (title → description → property chips), no sticky bottom
* toolbar. Property chips are part of the form, not pinned above keyboard.
* MentionSuggestionBar floats above keyboard only when the user is mid-@.
*
* No markdown toolbar / upload buttons in v1: mobile users creating an
* issue rarely format markdown, and attachment upload is deferred to a
* later release (see plan-issue-majestic-rabin.md "skip uploads").
*
* Mention pipeline shares `useMentionInput` with `issue/[id]/new-comment.tsx`
* — both surfaces produce canonical `[@name](mention://type/id)` markdown
* recognised by util.ParseMentions on the server.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
TextInput,
} from "react-native";
import { Stack, router } from "expo-router";
import { SubmitIssueButton } from "@/components/issue/submit-issue-button";
import { CreateFormAttributeRow } from "@/components/issue/create-form-attribute-row";
import { MentionSuggestionBar } from "@/components/issue/mention-suggestion-bar";
import { DescriptionField } from "@/components/issue/description-field";
import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens";
import { useCreateIssue } from "@/data/mutations/issues";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
import { useMentionInput } from "@/lib/use-mention-input";
export default function NewIssueModal() {
const [title, setTitle] = useState("");
const description = useMentionInput();
// Attribute chips (status / priority / assignee / due date / project)
// live in `useNewIssueDraftStore` so the new-issue-picker/* formSheet
// routes can read and write the same values without a parent-child
// React relationship. The store is reset on mount + on unmount so
// re-opening the new-issue modal starts clean.
const status = useNewIssueDraftStore((s) => s.status);
const priority = useNewIssueDraftStore((s) => s.priority);
const assignee = useNewIssueDraftStore((s) => s.assignee);
const dueDate = useNewIssueDraftStore((s) => s.dueDate);
const project = useNewIssueDraftStore((s) => s.project);
const resetDraft = useNewIssueDraftStore((s) => s.reset);
useEffect(() => {
resetDraft();
return () => {
resetDraft();
};
}, [resetDraft]);
const createIssue = useCreateIssue();
const isSubmitting = createIssue.isPending;
const canSubmit = !isSubmitting && title.trim().length > 0;
const onSubmit = useCallback(async () => {
const trimmedTitle = title.trim();
if (trimmedTitle.length === 0) return;
const finalDescription = description.serialize().trim();
try {
await createIssue.mutateAsync({
title: trimmedTitle,
description: finalDescription || undefined,
status,
priority,
...(assignee
? { assignee_type: assignee.type, assignee_id: assignee.id }
: {}),
...(dueDate ? { due_date: dueDate } : {}),
...(project ? { project_id: project.id } : {}),
});
router.back();
} catch (err) {
Alert.alert(
"Failed to create issue",
err instanceof Error ? err.message : "Unknown error",
);
}
}, [
title,
description,
status,
priority,
assignee,
dueDate,
project,
createIssue,
]);
const headerRight = useMemo(() => {
function HeaderRight() {
return (
<SubmitIssueButton
disabled={!canSubmit}
loading={isSubmitting}
onPress={onSubmit}
/>
);
}
return HeaderRight;
}, [canSubmit, isSubmitting, onSubmit]);
return (
<>
<Stack.Screen options={{ headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Issue title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-2xl font-semibold text-foreground py-2"
autoFocus
returnKeyType="next"
editable={!isSubmitting}
/>
<DescriptionField
description={description}
disabled={isSubmitting}
/>
<CreateFormAttributeRow />
</ScrollView>
{/* Mention suggestions float above the keyboard only when the user
types `@`. Self-hides via `if (!visible) return null` so it
doesn't take space at rest. */}
<MentionSuggestionBar {...description.suggestionBar} />
</KeyboardAvoidingView>
</>
);
}

View File

@@ -0,0 +1,21 @@
/**
* Priority picker route for the in-progress new-project draft. See ./status.tsx.
*/
import { router } from "expo-router";
import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body";
import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
export default function NewProjectPriorityPickerRoute() {
const priority = useNewProjectDraftStore((s) => s.priority);
const setPriority = useNewProjectDraftStore((s) => s.setPriority);
return (
<ProjectPriorityPickerBody
value={priority}
onChange={(next) => {
setPriority(next);
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,24 @@
/**
* Status picker route for the in-progress new-project draft. Reads/writes
* `useNewProjectDraftStore` — the project/new.tsx modal owns the draft and
* reads from the same store. See ../project/new.tsx for the lifecycle, and
* ../new-issue-picker/status.tsx for the mirror pattern.
*/
import { router } from "expo-router";
import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body";
import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
export default function NewProjectStatusPickerRoute() {
const status = useNewProjectDraftStore((s) => s.status);
const setStatus = useNewProjectDraftStore((s) => s.setStatus);
return (
<ProjectStatusPickerBody
value={status}
onChange={(next) => {
setStatus(next);
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,238 @@
/**
* Project detail screen. Single column, scrolling:
*
* Header card (icon + title + description, tap → edit)
* Properties section (Status / Priority / Lead — tap chip → picker)
* Resources section (read-only by default, "Add" button → resource form)
* Related issues (Open / Done bucketed list)
*
* Per-record realtime: `useProjectRealtime(id, onDeleted=back)` subscribes
* to `project:updated` (full replace) and `project:deleted` (pop back).
*
* Right-top "…" menu (ActionSheetIOS) → Edit / Delete. Delete asks for
* confirmation via `Alert.alert` per iOS HIG (destructive actions need
* a second tap).
*/
import { useCallback } from "react";
import {
ActionSheetIOS,
ActivityIndicator,
Alert,
Linking,
RefreshControl,
ScrollView,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ProjectHeaderCard } from "@/components/project/project-header-card";
import { ProjectPropertiesSection } from "@/components/project/project-properties-section";
import { ProjectRelatedIssues } from "@/components/project/project-related-issues";
import { ProjectResourcesSection } from "@/components/project/project-resources-section";
import {
projectDetailOptions,
projectResourcesOptions,
} from "@/data/queries/projects";
import { issueKeys } from "@/data/queries/issue-keys";
import { useDeleteProject } from "@/data/mutations/projects";
import { pinListOptions } from "@/data/queries/pins";
import { useCreatePin, useDeletePin } from "@/data/mutations/pins";
import { useAuthStore } from "@/data/auth-store";
import { useProjectRealtime } from "@/data/realtime/use-project-realtime";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function ProjectDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const qc = useQueryClient();
const detail = useQuery(projectDetailOptions(wsId, id));
const deleteProject = useDeleteProject(id);
// Per-record realtime — when another client deletes the project we're
// viewing, pop back so the user isn't stranded on a 404.
useProjectRealtime(id, () => router.back());
const onRefresh = useCallback(async () => {
await Promise.all([
detail.refetch(),
qc.invalidateQueries({ queryKey: projectResourcesOptions(wsId, id).queryKey }),
qc.invalidateQueries({
queryKey: [...issueKeys.list(wsId), "byProject", id],
}),
]);
}, [detail, qc, wsId, id]);
const project = detail.data;
// EMPTY_PROJECT carries an empty id — parseWithFallback returned the
// fallback because the response shape drifted. Treat as "not found".
const projectMissing = !project || project.id === "";
const userId = useAuthStore((s) => s.user?.id ?? null);
const { data: pins } = useQuery(pinListOptions(wsId, userId));
const isPinned =
!!project &&
!!pins?.some(
(p) => p.item_type === "project" && p.item_id === project.id,
);
const createPin = useCreatePin();
const deletePin = useDeletePin();
const onPressMore = () => {
if (!project) return;
const wsUrl = process.env.EXPO_PUBLIC_WEB_URL;
const options = [
"Cancel",
isPinned ? "Unpin" : "Pin",
"Edit details",
...(wsUrl ? ["Open on web"] : []),
"Delete",
];
const destructiveIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: destructiveIndex,
},
(i) => {
const label = options[i];
if (label === "Pin") {
createPin.mutate({ item_type: "project", item_id: project.id });
return;
}
if (label === "Unpin") {
deletePin.mutate({ itemType: "project", itemId: project.id });
return;
}
if (label === "Edit details") {
if (wsSlug) router.push(`/${wsSlug}/project/${id}/edit`);
return;
}
if (label === "Open on web" && wsUrl) {
Linking.openURL(`${wsUrl}/${wsSlug}/projects/${id}`);
return;
}
if (i === destructiveIndex) {
onDelete();
}
},
);
};
const onDelete = () => {
Alert.alert(
"Delete project?",
"This cannot be undone. Issues in this project will become unassigned from any project.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteProject.mutate(undefined, {
onSuccess: () => router.back(),
});
},
},
],
);
};
return (
<SafeAreaView className="flex-1 bg-background" edges={["bottom"]}>
<Stack.Screen
options={{
title: project?.title || "Project",
headerBackTitle: "Back",
headerRight: project
? () => (
<IconButton
name="ellipsis-horizontal"
onPress={onPressMore}
accessibilityLabel="Project actions"
/>
)
: undefined,
}}
/>
{detail.isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : detail.error || projectMissing ? (
<View className="flex-1 items-center justify-center px-6 gap-3">
<Text className="text-sm text-destructive text-center">
Failed to load project:{" "}
{detail.error instanceof Error
? detail.error.message
: "not found"}
</Text>
<Button variant="outline" onPress={() => detail.refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : (
<ScrollView
contentContainerClassName="pb-10"
refreshControl={
<RefreshControl
refreshing={detail.isRefetching}
onRefresh={onRefresh}
/>
}
keyboardDismissMode="on-drag"
>
<ProjectHeaderCard
project={project}
onEdit={() => {
if (wsSlug) router.push(`/${wsSlug}/project/${id}/edit`);
}}
/>
<ProjectPropertiesSection
project={project}
onPressStatus={() => {
if (wsSlug)
router.push({
pathname: "/[workspace]/project/[id]/picker/status",
params: { workspace: wsSlug, id },
});
}}
onPressPriority={() => {
if (wsSlug)
router.push({
pathname: "/[workspace]/project/[id]/picker/priority",
params: { workspace: wsSlug, id },
});
}}
onPressLead={() => {
if (wsSlug)
router.push({
pathname: "/[workspace]/project/[id]/picker/lead",
params: { workspace: wsSlug, id },
});
}}
/>
<ProjectResourcesSection
projectId={id}
onAdd={() => {
if (wsSlug)
router.push({
pathname: "/[workspace]/project/[id]/add-resource",
params: { workspace: wsSlug, id },
});
}}
/>
<View className="h-3" />
<ProjectRelatedIssues projectId={id} />
</ScrollView>
)}
</SafeAreaView>
);
}

View File

@@ -0,0 +1,94 @@
/**
* Add-resource (GitHub repo) sheet for a project — presented as a formSheet
* by the parent Stack. Self-contained: takes the URL + optional label,
* fires useCreateProjectResource, surfaces errors with Alert.
*
* v1 only supports `github_repo` resource type. Loose client-side
* validation: URL must look like `https://github.com/owner/repo`. Server
* is the canonical validator (validateAndNormalizeResourceRef in Go).
*/
import { useCallback, useState } from "react";
import { Alert, Pressable, View } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { Text } from "@/components/ui/text";
import { TextField } from "@/components/ui/text-field";
import { useCreateProjectResource } from "@/data/mutations/projects";
const GITHUB_PATTERN = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\/|$)/i;
export default function AddResourceRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const createResource = useCreateProjectResource(id);
const [url, setUrl] = useState("");
const [label, setLabel] = useState("");
const valid = GITHUB_PATTERN.test(url.trim());
const submitting = createResource.isPending;
const onSubmit = useCallback(() => {
if (!valid || submitting) return;
createResource.mutate(
{
resource_type: "github_repo",
resource_ref: { url: url.trim() },
label: label.trim() || undefined,
},
{
onSuccess: () => router.back(),
onError: (err) => {
Alert.alert(
"Failed to attach resource",
err instanceof Error ? err.message : "Unknown error",
);
},
},
);
}, [valid, submitting, createResource, url, label]);
return (
<View className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-4 pb-2">
<Text className="text-base font-semibold text-foreground">
Attach repository
</Text>
<Pressable
onPress={onSubmit}
disabled={!valid || submitting}
hitSlop={6}
className={`px-3 py-1.5 rounded-md ${
!valid || submitting ? "opacity-50" : "active:bg-secondary"
}`}
>
<Text className="text-sm font-semibold text-primary">
{submitting ? "Attaching…" : "Attach"}
</Text>
</Pressable>
</View>
<View className="px-4 pt-4 gap-4">
<View className="gap-1">
<Text className="text-xs text-muted-foreground">Repository URL</Text>
<TextField
value={url}
onChangeText={setUrl}
placeholder="https://github.com/owner/repo"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
autoFocus
/>
</View>
<View className="gap-1">
<Text className="text-xs text-muted-foreground">
Label (optional)
</Text>
<TextField
value={label}
onChangeText={setLabel}
placeholder="e.g. Backend"
/>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,201 @@
/**
* Edit project title / description / icon. Modal presentation, configured
* in `[workspace]/_layout.tsx`. Save button in the header runs an
* optimistic `useUpdateProject`; the modal dismisses on success.
*
* Cancel/dismiss flow: header Cancel + iOS drag-down gesture both check
* dirty state and pop an Alert if there are unsaved edits.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
TextInput,
View,
} from "react-native";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { AutosizeTextArea } from "@/components/ui/autosize-textarea";
import {
MIN_BODY_INPUT_HEIGHT_PX,
MOBILE_PLACEHOLDER_COLOR,
} from "@/components/ui/input-tokens";
import { projectDetailOptions } from "@/data/queries/projects";
import { useUpdateProject } from "@/data/mutations/projects";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function EditProject() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const detail = useQuery(projectDetailOptions(wsId, id));
const update = useUpdateProject(id);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [seeded, setSeeded] = useState(false);
// Seed local state once detail lands. Effect (not setState-in-render)
// so we don't accidentally retrigger on every parent re-render — the
// `seeded` guard makes it idempotent.
useEffect(() => {
if (!detail.data || seeded) return;
setTitle(detail.data.title);
setDescription(detail.data.description ?? "");
setIcon(detail.data.icon ?? "");
setSeeded(true);
}, [detail.data, seeded]);
const dirty = useMemo(() => {
if (!detail.data) return false;
return (
title.trim() !== detail.data.title ||
description.trim() !== (detail.data.description ?? "") ||
icon.trim() !== (detail.data.icon ?? "")
);
}, [detail.data, title, description, icon]);
const canSave =
seeded && title.trim().length > 0 && dirty && !update.isPending;
const onCancel = useCallback(() => {
if (!dirty) {
router.back();
return;
}
Alert.alert(
"Discard changes?",
"Your edits to this project will be lost.",
[
{ text: "Keep editing", style: "cancel" },
{
text: "Discard",
style: "destructive",
onPress: () => router.back(),
},
],
);
}, [dirty]);
const onSave = useCallback(() => {
if (!canSave) return;
const patch = {
title: title.trim(),
description: description.trim() || null,
icon: icon.trim() || null,
};
update.mutate(patch, {
onSuccess: () => router.back(),
onError: (err) => {
Alert.alert(
"Failed to save",
err instanceof Error ? err.message : "Unknown error",
);
},
});
}, [canSave, title, description, icon, update]);
const headerLeft = useCallback(() => {
return (
<Pressable onPress={onCancel} className="px-1 py-1">
<Text className="text-base text-brand">Cancel</Text>
</Pressable>
);
}, [onCancel]);
const headerRight = useCallback(() => {
return (
<Pressable
onPress={onSave}
disabled={!canSave}
className={canSave ? "px-1 py-1" : "px-1 py-1 opacity-40"}
>
<Text className="text-base text-brand font-semibold">
{update.isPending ? "Saving…" : "Save"}
</Text>
</Pressable>
);
}, [canSave, onSave, update.isPending]);
return (
<>
<Stack.Screen options={{ headerLeft, headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
{!detail.data ? (
<Text className="text-sm text-muted-foreground">Loading</Text>
) : (
<>
<Field label="Icon (emoji)">
<TextInput
value={icon}
onChangeText={(v) => {
// Cap at two characters — emoji are usually 1-2 UTF-16
// code units. Prevents the user typing a full sentence
// by accident.
setIcon(v.slice(0, 4));
}}
placeholder="📦"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-2xl text-foreground bg-secondary/50 rounded-md px-3 py-2 self-start min-w-[60px] text-center"
maxLength={4}
/>
</Field>
<Field label="Title">
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Project title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
autoFocus={!detail.data?.title}
returnKeyType="next"
/>
</Field>
<Field label="Description">
<AutosizeTextArea
value={description}
onChangeText={setDescription}
placeholder="What is this project about?"
className="bg-secondary/50 rounded-md px-3 py-2"
minHeight={MIN_BODY_INPUT_HEIGHT_PX}
/>
</Field>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</Text>
{children}
</View>
);
}

View File

@@ -0,0 +1,42 @@
/**
* Project lead picker route — presented as a formSheet by the parent Stack
* with iOS-native nav header + UISearchController via `useNativeSearchBar`.
* Self-contained: reads project from cache, fires useUpdateProject directly.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { ProjectLeadPickerBody } from "@/components/project/pickers/project-lead-picker-body";
import { projectDetailOptions } from "@/data/queries/projects";
import { useUpdateProject } from "@/data/mutations/projects";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function ProjectLeadPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: project } = useQuery(projectDetailOptions(wsId, id));
const updateProject = useUpdateProject(id);
const query = useNativeSearchBar("Search members or agents", {
autoFocus: true,
});
const value =
project?.lead_type && project?.lead_id
? { type: project.lead_type, id: project.lead_id }
: null;
return (
<ProjectLeadPickerBody
value={value}
query={query}
onChange={(next) => {
if (next === null) {
updateProject.mutate({ lead_type: null, lead_id: null });
} else {
updateProject.mutate({ lead_type: next.type, lead_id: next.id });
}
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,28 @@
/**
* Project priority picker route — presented as a formSheet by the parent
* Stack. Self-contained: reads project from cache, fires useUpdateProject
* on selection, then router.back()s.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body";
import { projectDetailOptions } from "@/data/queries/projects";
import { useUpdateProject } from "@/data/mutations/projects";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function ProjectPriorityPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: project } = useQuery(projectDetailOptions(wsId, id));
const updateProject = useUpdateProject(id);
return (
<ProjectPriorityPickerBody
value={project?.priority ?? "none"}
onChange={(next) => {
updateProject.mutate({ priority: next });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,28 @@
/**
* Project status picker route — presented as a formSheet by the parent
* Stack. Self-contained: reads project from cache, fires useUpdateProject
* on selection, then router.back()s.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body";
import { projectDetailOptions } from "@/data/queries/projects";
import { useUpdateProject } from "@/data/mutations/projects";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function ProjectStatusPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: project } = useQuery(projectDetailOptions(wsId, id));
const updateProject = useUpdateProject(id);
return (
<ProjectStatusPickerBody
value={project?.status ?? "planned"}
onChange={(next) => {
updateProject.mutate({ status: next });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,270 @@
/**
* New project modal. Mirrors `new-issue.tsx` shape — vertical form, header
* Cancel / Create buttons. Title is required; everything else has a default
* (status=planned, priority=none, no lead, no description, no icon).
*
* Lead is intentionally NOT exposed in the create form. Web does the same:
* lead assignment is a follow-up action because most users create the
* project from a "I need to track this stream of work" intent and figure
* out who's leading it later. The picker lives on the detail screen.
*
* Status / priority cross-route through `useNewProjectDraftStore` so the
* formSheet picker routes can read/write them — same pattern as
* new-issue.tsx + new-issue-picker/* (see new-project-draft-store.ts).
*
* On success: dismiss modal → navigate to the new project's detail page so
* the user can immediately add a lead / attach issues / configure properties.
*/
import { useCallback, useState } from "react";
import {
Alert,
InteractionManager,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
TextInput,
View,
} from "react-native";
import { Stack, router } from "expo-router";
import { Text } from "@/components/ui/text";
import { AutosizeTextArea } from "@/components/ui/autosize-textarea";
import {
MIN_BODY_INPUT_HEIGHT_PX,
MOBILE_PLACEHOLDER_COLOR,
} from "@/components/ui/input-tokens";
import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon";
import {
projectPriorityLabel,
projectStatusLabel,
} from "@/lib/project-status";
import { useCreateProject } from "@/data/mutations/projects";
import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
import { useWorkspaceStore } from "@/data/workspace-store";
/**
* Typed map of new-project picker route pathnames. Keeps `router.push` calls
* compile-checked rather than depending on free-form template strings —
* same approach as `create-form-attribute-row.tsx`.
*/
type NewProjectPickerField = "status" | "priority";
const NEW_PROJECT_PICKER_PATHNAMES = {
status: "/[workspace]/new-project-picker/status",
priority: "/[workspace]/new-project-picker/priority",
} as const satisfies Record<NewProjectPickerField, string>;
export default function NewProject() {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const create = useCreateProject();
const [title, setTitle] = useState("");
const [icon, setIcon] = useState("");
const [description, setDescription] = useState("");
const status = useNewProjectDraftStore((s) => s.status);
const priority = useNewProjectDraftStore((s) => s.priority);
const resetDraft = useNewProjectDraftStore((s) => s.reset);
const dirty =
title.length > 0 ||
icon.length > 0 ||
description.length > 0 ||
status !== "planned" ||
priority !== "none";
const canCreate = title.trim().length > 0 && !create.isPending;
const openPicker = useCallback(
(field: NewProjectPickerField) => {
if (!wsSlug) return;
router.push({
pathname: NEW_PROJECT_PICKER_PATHNAMES[field],
params: { workspace: wsSlug },
});
},
[wsSlug],
);
const onCancel = useCallback(() => {
if (!dirty) {
resetDraft();
router.back();
return;
}
Alert.alert(
"Discard project?",
"Your draft will be lost.",
[
{ text: "Keep editing", style: "cancel" },
{
text: "Discard",
style: "destructive",
onPress: () => {
resetDraft();
router.back();
},
},
],
);
}, [dirty, resetDraft]);
const onCreate = useCallback(() => {
if (!canCreate) return;
create.mutate(
{
title: title.trim(),
description: description.trim() || undefined,
icon: icon.trim() || undefined,
status,
priority,
},
{
onSuccess: (project) => {
resetDraft();
router.back();
// Wait for the modal dismiss animation to finish before pushing
// the detail screen. `InteractionManager` resolves once iOS
// says all in-flight animations / interactions are done — more
// robust than a hard-coded `setTimeout(150)` if iOS timing
// changes or the device is under load.
InteractionManager.runAfterInteractions(() => {
if (wsSlug) router.push(`/${wsSlug}/project/${project.id}`);
});
},
onError: (err) => {
Alert.alert(
"Failed to create project",
err instanceof Error ? err.message : "Unknown error",
);
},
},
);
}, [
canCreate,
create,
title,
description,
icon,
status,
priority,
wsSlug,
resetDraft,
]);
const headerLeft = useCallback(() => {
return (
<Pressable onPress={onCancel} className="px-1 py-1">
<Text className="text-base text-brand">Cancel</Text>
</Pressable>
);
}, [onCancel]);
const headerRight = useCallback(() => {
return (
<Pressable
onPress={onCreate}
disabled={!canCreate}
className={canCreate ? "px-1 py-1" : "px-1 py-1 opacity-40"}
>
<Text className="text-base text-brand font-semibold">
{create.isPending ? "Creating…" : "Create"}
</Text>
</Pressable>
);
}, [canCreate, onCreate, create.isPending]);
return (
<>
<Stack.Screen options={{ headerLeft, headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
<Field label="Icon (emoji)">
<TextInput
value={icon}
onChangeText={(v) => setIcon(v.slice(0, 4))}
placeholder="📦"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-2xl text-foreground bg-secondary/50 rounded-md px-3 py-2 self-start min-w-[60px] text-center"
maxLength={4}
/>
</Field>
<Field label="Title">
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Project title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
autoFocus
returnKeyType="next"
/>
</Field>
<Field label="Description">
<AutosizeTextArea
value={description}
onChangeText={setDescription}
placeholder="What is this project about?"
className="bg-secondary/50 rounded-md px-3 py-2"
minHeight={MIN_BODY_INPUT_HEIGHT_PX}
/>
</Field>
<View className="flex-row gap-2">
<View className="flex-1">
<Field label="Status">
<Pressable
onPress={() => openPicker("status")}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
<ProjectStatusIcon status={status} size={16} />
<Text className="text-sm text-foreground flex-1">
{projectStatusLabel(status)}
</Text>
</Pressable>
</Field>
</View>
<View className="flex-1">
<Field label="Priority">
<Pressable
onPress={() => openPicker("priority")}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
<ProjectPriorityIcon priority={priority} size={16} />
<Text className="text-sm text-foreground flex-1">
{projectPriorityLabel(priority)}
</Text>
</Pressable>
</Field>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</Text>
{children}
</View>
);
}

View File

@@ -0,0 +1,502 @@
/**
* Workspace global search modal.
*
* Mirrors packages/views/search/search-command.tsx but is scoped to
* search-only — mobile IA puts page nav in the More popover and
* workspace switching in Settings, so a command-palette here would
* duplicate them (see feedback_mobile_ia_main_vs_more).
*
* Result categories, ordering (projects first, issues second), debounce
* (300ms), abort policy, and Recent rendering mirror the web source.
* Highlight + snippet line for `match_source` matches preserves the
* "why did this match" signal users rely on when scanning results.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
TextInput,
View,
type ListRenderItem,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQueries } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type {
Issue,
IssueStatus,
SearchIssueResult,
SearchProjectResult,
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { ProjectIcon } from "@/components/ui/project-icon";
import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
import { api } from "@/data/api";
import { useWorkspaceStore } from "@/data/workspace-store";
import {
selectViewedIssueIds,
useViewedIssuesStore,
} from "@/data/viewed-issues-store";
import { issueDetailOptions } from "@/data/queries/issues";
import { STATUS_LABEL } from "@/lib/issue-status";
import { projectStatusLabel } from "@/lib/project-status";
const DEBOUNCE_MS = 300;
const ISSUE_LIMIT = 20;
const PROJECT_LIMIT = 10;
const RECENT_LIMIT = 5;
// =====================================================
// HighlightText — mobile port of web's HighlightText
// =====================================================
// Web uses an HTML <mark> which doesn't exist in RN, so we segment the
// string ourselves and wrap matched parts in a styled <Text>. Same regex
// escape + case-insensitive substring match as
// packages/views/search/search-command.tsx:55-89.
interface HighlightTextProps {
text: string;
query: string;
className?: string;
numberOfLines?: number;
}
function HighlightText({
text,
query,
className,
numberOfLines,
}: HighlightTextProps) {
const parts = useMemo(() => {
const q = query.trim();
if (!q) return [{ text, hit: false }];
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escaped})`, "gi");
const out: { text: string; hit: boolean }[] = [];
let last = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(text)) !== null) {
if (m.index > last) out.push({ text: text.slice(last, m.index), hit: false });
out.push({ text: m[0], hit: true });
last = regex.lastIndex;
}
if (last < text.length) out.push({ text: text.slice(last), hit: false });
return out.length > 0 ? out : [{ text, hit: false }];
}, [text, query]);
return (
<Text className={className} numberOfLines={numberOfLines}>
{parts.map((p, i) =>
p.hit ? (
// Inline hex (yellow-200) instead of a Tailwind class because the
// mobile tailwind.config.js intentionally curates its own palette
// (no `yellow-*`) — see apps/mobile/CLAUDE.md "Visual tokens".
<Text
key={i}
className="text-foreground"
style={{ backgroundColor: "#fef08a" }}
>
{p.text}
</Text>
) : (
<Text key={i}>{p.text}</Text>
),
)}
</Text>
);
}
// =====================================================
// Row item types — drives the single FlatList render
// =====================================================
type RowItem =
| { kind: "header"; key: string; title: string }
| { kind: "issue"; key: string; issue: SearchIssueResult; query: string }
| { kind: "project"; key: string; project: SearchProjectResult; query: string }
| { kind: "recent"; key: string; issue: Issue };
function issueIconColor(status: IssueStatus): string {
// Tag color for the status label at the end of an issue row.
// Mirrors STATUS_CONFIG.iconColor (status-icon.tsx STATUS_COLOR) so the
// text tint matches the leading status icon visually.
switch (status) {
case "in_progress":
return "text-warning";
case "in_review":
return "text-success";
case "done":
return "text-info";
case "blocked":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
function navigateOnTap(slug: string | null, path: string) {
// Search is `presentation: "modal"` (see (app)/[workspace]/_layout.tsx).
// `router.replace` swaps the modal out for the destination in a single
// atomic transition — the new screen renders with its own presentation
// (default `card`), and the resulting history is `[..., inbox, detail]`,
// so the user's back gesture lands on the screen that was under search.
if (!slug) return;
router.replace(path);
}
interface SearchIssueRowProps {
item: SearchIssueResult;
query: string;
slug: string | null;
}
function SearchIssueRow({ item, query, slug }: SearchIssueRowProps) {
// Web only renders the snippet line for comment matches
// (packages/views/search/search-command.tsx:632) and the backend only
// populates `matched_snippet` for comment matches anyway
// (server/internal/handler/issue.go:592). Keep mobile strictly aligned.
const showSnippet =
item.match_source === "comment" && !!item.matched_snippet;
const statusLabel = STATUS_LABEL[item.status as IssueStatus] ?? item.status;
return (
<Pressable
onPress={() => navigateOnTap(slug, `/${slug}/issue/${item.id}`)}
className="active:bg-secondary px-4 py-3"
>
<View className="flex-row items-center gap-3">
<StatusIcon status={item.status as IssueStatus} size={14} />
<PriorityIcon priority={item.priority} size={14} />
<Text className="text-xs text-muted-foreground shrink-0 w-16">
{item.identifier}
</Text>
<View className="flex-1">
<HighlightText
text={item.title}
query={query}
className="text-sm text-foreground"
numberOfLines={1}
/>
</View>
<Text className={`text-xs shrink-0 ${issueIconColor(item.status as IssueStatus)}`}>
{statusLabel}
</Text>
</View>
{showSnippet ? (
<View className="flex-row items-start gap-2 mt-1 pl-[68px]">
<Ionicons
name="chatbubble-outline"
size={12}
color="#71717a"
style={{ marginTop: 2 }}
/>
<View className="flex-1">
<HighlightText
text={item.matched_snippet ?? ""}
query={query}
className="text-xs text-muted-foreground"
numberOfLines={1}
/>
</View>
</View>
) : null}
</Pressable>
);
}
interface SearchProjectRowProps {
item: SearchProjectResult;
query: string;
slug: string | null;
}
function SearchProjectRow({ item, query, slug }: SearchProjectRowProps) {
const showSnippet =
item.match_source === "description" && !!item.matched_snippet;
return (
<Pressable
onPress={() => navigateOnTap(slug, `/${slug}/project/${item.id}`)}
className="active:bg-secondary px-4 py-3"
>
<View className="flex-row items-center gap-3">
<ProjectIcon icon={item.icon} size="md" />
<View className="flex-1">
<HighlightText
text={item.title}
query={query}
className="text-sm text-foreground"
numberOfLines={1}
/>
</View>
<View className="flex-row items-center gap-1.5 shrink-0">
<ProjectStatusIcon status={item.status} size={12} />
<Text className="text-xs text-muted-foreground">
{projectStatusLabel(item.status)}
</Text>
</View>
</View>
{showSnippet ? (
<View className="flex-row items-start mt-1 pl-[36px]">
<View className="flex-1">
<HighlightText
text={item.matched_snippet ?? ""}
query={query}
className="text-xs text-muted-foreground"
numberOfLines={1}
/>
</View>
</View>
) : null}
</Pressable>
);
}
interface RecentRowProps {
item: Issue;
slug: string | null;
}
function RecentRow({ item, slug }: RecentRowProps) {
const statusLabel = STATUS_LABEL[item.status as IssueStatus] ?? item.status;
return (
<Pressable
onPress={() => navigateOnTap(slug, `/${slug}/issue/${item.id}`)}
className="active:bg-secondary px-4 py-3"
>
<View className="flex-row items-center gap-3">
<StatusIcon status={item.status as IssueStatus} size={14} />
<Text className="text-xs text-muted-foreground shrink-0 w-16">
{item.identifier}
</Text>
<Text className="flex-1 text-sm text-foreground" numberOfLines={1}>
{item.title}
</Text>
<Text className={`text-xs shrink-0 ${issueIconColor(item.status as IssueStatus)}`}>
{statusLabel}
</Text>
</View>
</Pressable>
);
}
// =====================================================
// Screen
// =====================================================
interface SearchResultsState {
issues: SearchIssueResult[];
projects: SearchProjectResult[];
}
const EMPTY_RESULTS: SearchResultsState = { issues: [], projects: [] };
export default function SearchModal() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResultsState>(EMPTY_RESULTS);
const [isLoading, setIsLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Recent — mirrors mention-suggestion-bar.tsx:85-95.
const viewedIds = useViewedIssuesStore(selectViewedIssueIds(wsId));
const recentIds = useMemo(
() => viewedIds.slice(0, RECENT_LIMIT),
[viewedIds],
);
const recentQueries = useQueries({
queries: recentIds.map((id) => issueDetailOptions(wsId, id)),
});
const recentIssues = useMemo<Issue[]>(
() =>
recentQueries
.map((q) => q.data)
.filter((i): i is Issue => !!i),
[recentQueries],
);
// Cleanup pending debounce + abort on unmount. Without this, navigating
// away mid-request leaves a dangling timeout + an in-flight fetch whose
// setState would warn against an unmounted component.
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortRef.current) abortRef.current.abort();
};
}, []);
const runSearch = useCallback((q: string) => {
// Race-correctness: clear the pending debounce AND abort any in-flight
// controller BEFORE the early-return / state writes below. The abort
// is synchronous (signal.aborted flips immediately), so the post-await
// guard in the timeout body will skip stale `setResults` / `setIsLoading`
// even if the network response arrives later.
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortRef.current) abortRef.current.abort();
if (!q.trim()) {
setResults(EMPTY_RESULTS);
setIsLoading(false);
return;
}
setIsLoading(true);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const [issueRes, projectRes] = await Promise.all([
api.searchIssues(
{ q: q.trim(), limit: ISSUE_LIMIT, include_closed: true },
{ signal: controller.signal },
),
api.searchProjects(
{ q: q.trim(), limit: PROJECT_LIMIT, include_closed: true },
{ signal: controller.signal },
),
]);
if (!controller.signal.aborted) {
setResults({ issues: issueRes.issues, projects: projectRes.projects });
setIsLoading(false);
}
} catch {
// Abort throws here too; ignore — a newer request is in flight, or
// the user dismissed the modal. Drift / network errors are already
// logged inside parseWithFallback + the api logger.
if (!controller.signal.aborted) setIsLoading(false);
}
}, DEBOUNCE_MS);
}, []);
const handleChange = useCallback(
(value: string) => {
setQuery(value);
runSearch(value);
},
[runSearch],
);
const trimmedQuery = query.trim();
const hasResults =
results.issues.length > 0 || results.projects.length > 0;
// Build the FlatList data. One flat array of discriminated rows means a
// single virtualised list covers Recent (empty-state) and (Projects +
// Issues) results without nesting SectionList inside another scroller.
const data = useMemo<RowItem[]>(() => {
if (!trimmedQuery) {
if (recentIssues.length === 0) return [];
return [
{ kind: "header", key: "h-recent", title: "Recent" },
...recentIssues.map<RowItem>((issue) => ({
kind: "recent",
key: `r-${issue.id}`,
issue,
})),
];
}
const items: RowItem[] = [];
if (results.projects.length > 0) {
items.push({ kind: "header", key: "h-projects", title: "Projects" });
for (const p of results.projects) {
items.push({ kind: "project", key: `p-${p.id}`, project: p, query: trimmedQuery });
}
}
if (results.issues.length > 0) {
items.push({ kind: "header", key: "h-issues", title: "Issues" });
for (const it of results.issues) {
items.push({ kind: "issue", key: `i-${it.id}`, issue: it, query: trimmedQuery });
}
}
return items;
}, [trimmedQuery, recentIssues, results]);
const renderItem = useCallback<ListRenderItem<RowItem>>(
({ item }) => {
switch (item.kind) {
case "header":
return (
<Text className="px-4 pt-4 pb-1 text-xs font-medium text-muted-foreground uppercase">
{item.title}
</Text>
);
case "issue":
return <SearchIssueRow item={item.issue} query={item.query} slug={slug} />;
case "project":
return <SearchProjectRow item={item.project} query={item.query} slug={slug} />;
case "recent":
return <RecentRow item={item.issue} slug={slug} />;
}
},
[slug],
);
return (
<SafeAreaView className="flex-1 bg-background" edges={["bottom"]}>
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{/* Search input row */}
<View className="flex-row items-center gap-3 border-b border-border px-4 py-2">
<Ionicons name="search" size={20} color="#71717a" />
<TextInput
value={query}
onChangeText={handleChange}
placeholder="Search issues and projects"
placeholderTextColor="#a1a1aa"
autoFocus
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
clearButtonMode="while-editing"
className="flex-1 text-base text-foreground"
/>
</View>
{/* Body */}
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.key}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
ListEmptyComponent={
isLoading ? (
<View className="items-center justify-center py-12">
<ActivityIndicator color="#71717a" />
</View>
) : trimmedQuery && !hasResults ? (
<View className="items-center justify-center py-12 px-6">
<Text className="text-sm text-muted-foreground text-center">
No results for &ldquo;{trimmedQuery}&rdquo;
</Text>
</View>
) : !trimmedQuery && recentIssues.length === 0 ? (
<View className="items-center justify-center py-12 px-6">
<Text className="text-sm text-muted-foreground text-center">
Type to search issues and projects.
</Text>
</View>
) : null
}
ListFooterComponent={
isLoading && hasResults ? (
<View className="items-center justify-center py-4">
<ActivityIndicator color="#71717a" />
</View>
) : null
}
/>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,142 @@
/**
* Workspace switcher — presented as a formSheet by the parent Stack.
*
* Reached from the More popover's WorkspaceCard (collapsed single-row entry).
* Lists every workspace the user belongs to, current one disabled with a
* checkmark. Tapping a non-current row triggers an iOS-native `Alert.alert`
* confirm — only after the user confirms do we dismiss the sheet and
* `router.replace` to the target slug.
*
* Why a confirm step:
* The previous flow ("popover → tap row → instant switch") had no friction
* against fat-finger taps in the cramped popover, and the user lost their
* entire navigation context (tabs, scroll position) with one accidental
* tap. iOS Alert is the platform-correct gate (mobile/CLAUDE.md Principle
* 3 — iOS native > RNR > discuss).
*
* Switching itself stays minimal: `router.dismiss()` to close this sheet,
* then `router.replace(/${slug}/inbox)`. The downstream WorkspaceRouteLayout
* handles `setCurrentWorkspace(slug, uuid)` on mount.
*/
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
View,
} from "react-native";
import { Image as ExpoImage } from "expo-image";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { Workspace } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { cn } from "@/lib/utils";
export default function SwitchWorkspaceRoute() {
const activeSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { colorScheme } = useColorScheme();
const t = THEME[colorScheme];
const { data, isLoading } = useQuery(workspaceListOptions());
const onSelect = (ws: Workspace) => {
if (ws.slug === activeSlug) return;
Alert.alert(
"切换工作区",
`确定切换到 "${ws.name}"?`,
[
{ text: "取消", style: "cancel" },
{
text: "切换",
onPress: () => {
router.dismiss();
router.replace(`/${ws.slug}/inbox`);
},
},
],
);
};
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">
</Text>
</View>
{isLoading ? (
<View className="py-6 items-center">
<ActivityIndicator />
</View>
) : (
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{(data ?? []).map((ws) => (
<WorkspaceRow
key={ws.id}
workspace={ws}
active={ws.slug === activeSlug}
onPress={() => onSelect(ws)}
iconTint={t.foreground}
mutedIconTint={t.mutedForeground}
/>
))}
</ScrollView>
)}
</View>
);
}
function WorkspaceRow({
workspace,
active,
onPress,
iconTint,
mutedIconTint,
}: {
workspace: Workspace;
active: boolean;
onPress: () => void;
iconTint: string;
mutedIconTint: string;
}) {
return (
<Pressable
onPress={onPress}
disabled={active}
accessibilityLabel={
active
? `${workspace.name}, 当前工作区`
: `切换到 ${workspace.name}`
}
className={cn(
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
active && "opacity-100",
)}
>
<ExpoImage
source="sf:building.2"
tintColor={active ? iconTint : mutedIconTint}
style={{ width: 18, height: 18 }}
/>
<Text
className={cn(
"flex-1 text-sm text-foreground",
active && "font-semibold",
)}
numberOfLines={1}
>
{workspace.name}
</Text>
{active ? (
<ExpoImage
source="sf:checkmark"
tintColor={iconTint}
style={{ width: 16, height: 16 }}
/>
) : null}
</Pressable>
);
}

View File

@@ -0,0 +1,15 @@
import { Stack, Redirect } from "expo-router";
import { useAuthStore } from "@/data/auth-store";
/**
* Auth-required layout. Redirects to /login when no user is loaded.
*
* Workspace membership is enforced one level deeper at [workspace]/_layout —
* not here — because select-workspace.tsx itself is auth-required but
* workspace-less.
*/
export default function AppLayout() {
const user = useAuthStore((s) => s.user);
if (!user) return <Redirect href="/login" />;
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,89 @@
import { ActivityIndicator, ScrollView, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { CardPressable } from "@/components/ui/card";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function SelectWorkspace() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const { data, isLoading, error, refetch } = useQuery(workspaceListOptions());
const onSelect = async (id: string, slug: string) => {
await setCurrentWorkspace(id, slug);
router.replace(`/${slug}/inbox`);
};
return (
<SafeAreaView className="flex-1 bg-background">
<ScrollView contentContainerClassName="px-6 py-6 gap-6">
<View className="gap-1">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
Signed in as
</Text>
<Text className="text-base text-foreground">{user?.email}</Text>
</View>
<View className="gap-3">
<Text className="text-2xl font-semibold text-foreground">
Select a workspace
</Text>
{isLoading ? (
<View className="py-8 items-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="gap-3">
<Text className="text-sm text-destructive">
Failed to load workspaces:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : !data || data.length === 0 ? (
<Text className="text-sm text-muted-foreground">
You don&apos;t belong to any workspaces yet. Contact your workspace
admin to be invited.
</Text>
) : (
<View className="gap-3">
{data.map((ws) => (
<CardPressable
key={ws.id}
onPress={() => onSelect(ws.id, ws.slug)}
>
<Text className="text-base font-semibold text-foreground">
{ws.name}
</Text>
<Text className="text-xs text-muted-foreground mt-1">
/{ws.slug}
</Text>
{ws.description ? (
<Text className="text-sm text-muted-foreground mt-2">
{ws.description}
</Text>
) : null}
</CardPressable>
))}
</View>
)}
</View>
<View className="pt-4 border-t border-border">
<Button variant="outline" onPress={() => logout()}>
<Text>Sign out</Text>
</Button>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import * as Haptics from "expo-haptics";
import { Text } from "@/components/ui/text";
import { TextField } from "@/components/ui/text-field";
import { Button } from "@/components/ui/button";
import { MulticaLogo } from "@/components/brand/multica-logo";
import { useAuthStore } from "@/data/auth-store";
import { mapAuthError } from "@/lib/auth-error";
export default function Login() {
const sendCode = useAuthStore((s) => s.sendCode);
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async () => {
const trimmed = email.trim();
if (!trimmed) return;
void Haptics.selectionAsync();
setSubmitting(true);
setError(null);
try {
await sendCode(trimmed);
router.push({ pathname: "/verify", params: { email: trimmed } });
} catch (err) {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setError(mapAuthError(err, "Couldn't send the code. Try again."));
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View className="flex-1 justify-center px-6 gap-6">
<View className="items-center gap-3">
<MulticaLogo size={32} />
<View className="gap-1 items-center">
<Text className="text-2xl font-semibold text-foreground">
Sign in to Multica
</Text>
<Text className="text-sm text-muted-foreground text-center">
Enter your email and we&apos;ll send you a verification code.
</Text>
</View>
</View>
<View className="gap-3">
<TextField
autoCapitalize="none"
autoComplete="email"
autoFocus
keyboardType="email-address"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
onSubmitEditing={onSubmit}
returnKeyType="send"
editable={!submitting}
invalid={!!error}
/>
{error ? (
<Text className="text-sm text-destructive">{error}</Text>
) : null}
</View>
<Button
size="lg"
disabled={submitting || !email.trim()}
onPress={onSubmit}
>
<Text>{submitting ? "Sending..." : "Send code"}</Text>
</Button>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from "react";
import { KeyboardAvoidingView, Platform, Pressable, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useLocalSearchParams } from "expo-router";
import * as Haptics from "expo-haptics";
import { Text } from "@/components/ui/text";
import { OtpInput, type OtpInputRef } from "@/components/ui/otp-input";
import { Button } from "@/components/ui/button";
import { MulticaLogo } from "@/components/brand/multica-logo";
import { useAuthStore } from "@/data/auth-store";
import { mapAuthError } from "@/lib/auth-error";
const CODE_LENGTH = 6;
const RESEND_COOLDOWN_SECONDS = 60;
export default function Verify() {
const sendCode = useAuthStore((s) => s.sendCode);
const verifyCode = useAuthStore((s) => s.verifyCode);
const { email = "" } = useLocalSearchParams<{ email?: string }>();
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS);
const [resending, setResending] = useState(false);
const otpRef = useRef<OtpInputRef>(null);
useEffect(() => {
if (cooldown <= 0) return;
const t = setInterval(() => {
setCooldown((c) => (c <= 1 ? 0 : c - 1));
}, 1000);
return () => clearInterval(t);
}, [cooldown]);
const submit = async (value: string) => {
if (!value || !email || submitting) return;
void Haptics.selectionAsync();
setSubmitting(true);
setError(null);
try {
await verifyCode(email, value);
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
router.replace("/");
} catch (err) {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setError(mapAuthError(err, "Couldn't verify the code. Try again."));
setSubmitting(false);
otpRef.current?.clear();
setCode("");
}
};
const onResend = async () => {
if (cooldown > 0 || resending || !email) return;
void Haptics.selectionAsync();
setResending(true);
setError(null);
try {
await sendCode(email);
setCooldown(RESEND_COOLDOWN_SECONDS);
otpRef.current?.clear();
setCode("");
} catch (err) {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setError(mapAuthError(err, "Couldn't resend the code. Try again."));
} finally {
setResending(false);
}
};
return (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View className="flex-1 justify-center px-6 gap-6">
<View className="items-center gap-3">
<MulticaLogo size={32} />
<View className="gap-1 items-center">
<Text className="text-2xl font-semibold text-foreground">
Enter verification code
</Text>
<Text className="text-sm text-muted-foreground text-center">
We sent a 6-digit code to {email}
</Text>
</View>
</View>
<View className="gap-3 items-center">
<OtpInput
ref={otpRef}
numberOfDigits={CODE_LENGTH}
value={code}
onChange={setCode}
onComplete={submit}
autoFocus
editable={!submitting}
/>
{error ? (
<Text className="text-sm text-destructive">{error}</Text>
) : null}
</View>
<View className="gap-3">
<Button
size="lg"
disabled={submitting || code.length < CODE_LENGTH}
onPress={() => submit(code)}
>
<Text>{submitting ? "Verifying..." : "Verify"}</Text>
</Button>
<Pressable
onPress={onResend}
disabled={cooldown > 0 || resending}
className="py-2 items-center"
>
<Text
className={
cooldown > 0 || resending
? "text-sm text-muted-foreground"
: "text-sm text-primary"
}
>
{resending
? "Sending..."
: cooldown > 0
? `Resend code in ${cooldown}s`
: "Resend code"}
</Text>
</Pressable>
<Button
variant="ghost"
disabled={submitting}
onPress={() => router.back()}
>
<Text>Use a different email</Text>
</Button>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,85 @@
import "../global.css";
import { useEffect, useRef } from "react";
import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { ThemeProvider } from "@react-navigation/native";
import { PortalHost } from "@rn-primitives/portal";
import { api } from "@/data/api";
import { queryClient } from "@/data/query-client";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { LightboxProvider, prewarmHighlighter } from "@/lib/markdown";
import { NAV_THEME } from "@/lib/theme";
import { useColorScheme } from "@/lib/use-color-scheme";
// Kick off Shiki highlighter init at module load — fires once per process,
// finishes before the user navigates to any screen with a code block. If
// init fails (engine unavailable) the highlighter falls back to plain
// text; nothing here is allowed to throw.
prewarmHighlighter();
function AuthInitializer({ children }: { children: React.ReactNode }) {
const initialize = useAuthStore((s) => s.initialize);
const qc = useQueryClient();
// Idempotent guard: 401 on multiple in-flight requests would otherwise
// logout/navigate repeatedly during the same session-expire moment.
const signingOutRef = useRef(false);
useEffect(() => {
// Wire 401 handling onto the shared ApiClient singleton. Must be set
// before any request fires — initialize() below kicks off the first
// getMe() call, so do this synchronously first.
api.setOptions({
onUnauthorized: () => {
if (signingOutRef.current) return;
signingOutRef.current = true;
void (async () => {
await useAuthStore.getState().logout();
await useWorkspaceStore.getState().clear();
qc.clear();
router.replace("/login");
// Reset on next tick so a fresh session can hit 401 again later
// without being silently swallowed.
setTimeout(() => {
signingOutRef.current = false;
}, 0);
})();
},
});
initialize();
}, [initialize, qc]);
return <>{children}</>;
}
export default function RootLayout() {
const { colorScheme, isDarkColorScheme } = useColorScheme();
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<KeyboardProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider value={NAV_THEME[colorScheme]}>
<AuthInitializer>
<LightboxProvider>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
</Stack>
<PortalHost />
</LightboxProvider>
</AuthInitializer>
</ThemeProvider>
</QueryClientProvider>
</KeyboardProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

30
apps/mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { ActivityIndicator, View } from "react-native";
import { Redirect } from "expo-router";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
/**
* Entry redirect. AuthInitializer (in _layout.tsx) finishes auth + slug
* hydration before this renders meaningfully — until then, isLoading is true.
*
* no user → /login
* user, no slug → /select-workspace
* user, slug → /[slug]/inbox
*/
export default function Index() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-background">
<ActivityIndicator />
</View>
);
}
if (!user) return <Redirect href="/login" />;
if (!slug) return <Redirect href="/select-workspace" />;
return <Redirect href={`/${slug}/inbox`} />;
}

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -0,0 +1,32 @@
/**
* Multica wordmark / sigil. 1:1 vector copy of docs/assets/logo-light.svg —
* keep this file and the SVG in sync.
*
* react-native-svg does not resolve CSS `currentColor`, so callers must pass
* `color` explicitly. For theme-aware usage, pair with `useColorScheme` +
* `THEME` token from `@/lib/theme`.
*/
import Svg, { Polygon } from "react-native-svg";
import { THEME } from "@/lib/theme";
import { useColorScheme } from "@/lib/use-color-scheme";
interface MulticaLogoProps {
size?: number;
color?: string;
}
export function MulticaLogo({ size = 48, color }: MulticaLogoProps) {
const { isDarkColorScheme } = useColorScheme();
const resolvedColor =
color ?? (isDarkColorScheme ? THEME.dark.foreground : THEME.light.foreground);
return (
<Svg width={size} height={size} viewBox="0 0 80 80">
<Polygon
fill={resolvedColor}
points="35,51.1 35,80 45,80 45,51.1 71.8,77.9 78.9,70.8 52.1,44 90,44 90,34 52.1,34 78.9,7.2 71.8,0.1 45,26.9 45,-11 35,-11 35,26.9 8.2,0.1 1.1,7.2 27.9,34 -10,34 -10,44 27.9,44 1.1,70.8 8.2,77.9"
transform="translate(5, 5.5) scale(0.87)"
/>
</Svg>
);
}

View File

@@ -0,0 +1,109 @@
/**
* Agent picker — bottom Modal listing agents the current user can assign /
* chat with. Shown when the user taps `+ New Chat` and the workspace has
* more than one usable agent; with exactly one, the chat screen skips this
* sheet and goes straight to the blank state for that agent.
*
* Filtering is delegated to the caller (the screen passes a pre-filtered
* `agents` list) so the same filter logic — archived + canAssignAgent +
* order — stays in one place.
*
* Layout mirrors `components/issue/my-issues-filter-sheet.tsx`: transparent
* Modal + dimmed backdrop + centered card. Bottom-sheet anchoring would be
* nicer but the current codebase doesn't pull in a bottom-sheet lib and
* centered cards already work well on iOS.
*/
import { Modal, Pressable, ScrollView, View } from "react-native";
import type { Agent } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { cn } from "@/lib/utils";
interface Props {
visible: boolean;
agents: Agent[];
currentAgentId: string | null;
onPick: (agent: Agent) => void;
onClose: () => void;
}
export function AgentPickerSheet({
visible,
agents,
currentAgentId,
onPick,
onClose,
}: Props) {
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-6">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl overflow-hidden">
<View className="px-4 py-3 border-b border-border">
<Text className="text-base font-semibold text-foreground">
Choose an agent
</Text>
</View>
<ScrollView className="max-h-96">
{agents.length === 0 ? (
<View className="px-4 py-8">
<Text className="text-sm text-muted-foreground text-center">
No agents available.
</Text>
</View>
) : (
agents.map((agent) => {
const selected = agent.id === currentAgentId;
return (
<Pressable
key={agent.id}
onPress={() => {
onPick(agent);
onClose();
}}
className={cn(
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
selected && "bg-secondary/60",
)}
>
<ActorAvatar type="agent" id={agent.id} size={32} showPresence />
<View className="flex-1">
<Text
className="text-sm font-medium text-foreground"
numberOfLines={1}
>
{agent.name}
</Text>
{agent.description ? (
<Text
className="text-xs text-muted-foreground mt-0.5"
numberOfLines={1}
>
{agent.description}
</Text>
) : null}
</View>
{selected ? (
<Text className="text-sm text-primary font-semibold">
</Text>
) : null}
</Pressable>
);
})
)}
</ScrollView>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Chat composer — thin wrapper around the shared `<MessageComposer>` with
* chat-specific wiring:
*
* - **Controlled text**: parent (chat.tsx) owns the draft via
* `useChatDraftsStore` so switching sessions rehydrates the right
* draft. Pass `value` + `onChangeText` through.
* - **Stop button**: while an agent task is running for the active
* session, `sending` flips true and we replace the Send button slot
* with a Stop affordance (filled foreground bg + stop glyph). Tap →
* `onStop()` cancels the in-flight task.
* - **Mention picker mode=chat**: chat is user ↔ single agent so
* @member / @agent / @squad / @all are noise + would notify the
* wrong people. Picker route honors `?mode=chat` and surfaces only
* Issues (useful for "reference this ticket for context").
* - **No reply target**: chat is a flat conversation; passes no
* reply chip.
* - **No upload context**: chat attachments are session-scoped; the
* server back-fills `chat_message_id` on each row when the message
* persists (server-side). `MessageComposer` calls `api.uploadFile`
* without `{ issueId, commentId }`.
* - **Parent owns keyboard**: chat.tsx wraps in KeyboardAvoidingView +
* SafeAreaView, so `manageKeyboard={false}` prevents the composer
* from double-stacking its own keyboard handling.
*
* Previously a hand-written 400-LOC twin of inline-comment-composer.tsx;
* now ~50 LOC plus the StopButton subcomponent.
*/
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import { MessageComposer } from "@/components/composer/message-composer";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
interface Props {
/** Current draft text (controlled). Empty string = no draft. */
value: string;
/** Fired on every keystroke. The caller writes to the drafts store. */
onChangeText: (next: string) => void;
/** Send the serialised markdown content + the completed attachments'
* server ids. Caller resets the input by setting `value=""` after a
* successful send. */
onSend: (content: string, attachmentIds: string[]) => Promise<void> | void;
/** Cancel the in-flight agent task. Only callable while `sending===true`. */
onStop: () => void;
/** True while an agent task is running for the active session. The
* composer swaps Send for Stop. */
sending: boolean;
/** Hard-disable typing + send. Used when there's no usable agent in the
* workspace or the session is archived (legacy). */
disabled?: boolean;
/** When `disabled`, replaces the pill label with the reason. */
disabledReason?: string;
}
const IS_IOS = process.env.EXPO_OS === "ios";
export function ChatComposer({
value,
onChangeText,
onSend,
onStop,
sending,
disabled = false,
disabledReason,
}: Props) {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const onSubmit = useCallback(
async ({
content,
attachmentIds,
}: {
content: string;
attachmentIds: string[];
}) => {
// `onSend` may be sync or async; await is safe in both cases. If it
// throws, MessageComposer's catch restores text + chips.
await onSend(content, attachmentIds);
},
[onSend],
);
const handleStop = useCallback(() => {
if (IS_IOS) {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
onStop();
}, [onStop]);
return (
<MessageComposer
value={value}
onChangeText={onChangeText}
onSubmit={onSubmit}
mentionPickerPath={{
pathname: "/[workspace]/mention-picker",
params: { workspace: wsSlug ?? "", mode: "chat" },
}}
placeholder={sending ? "Agent is working…" : "Message…"}
pillLabel={
sending
? "Agent is working…"
: disabled
? (disabledReason ?? "Chat unavailable")
: "Message…"
}
pillIcon="chatbubble-ellipses-outline"
disabled={disabled}
disabledReason={disabledReason}
isSending={sending}
renderStop={() => <StopButton onPress={handleStop} />}
manageKeyboard={false}
/>
);
}
function StopButton({ onPress }: { onPress: () => void }) {
const { colorScheme } = useColorScheme();
const theme = THEME[colorScheme];
return (
<Animated.View
key="stop"
entering={FadeIn.duration(120)}
exiting={FadeOut.duration(120)}
>
<Pressable
onPress={onPress}
className="h-8 w-8 items-center justify-center rounded-full bg-foreground active:opacity-80"
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Stop agent"
>
<View
style={{
width: 10,
height: 10,
backgroundColor: theme.background,
borderRadius: 1.5,
}}
/>
</Pressable>
</Animated.View>
);
}

View File

@@ -0,0 +1,93 @@
/**
* Empty-state surface shown when the active session has no messages.
*
* Two modes mirror web (packages/views/chat/components/chat-window.tsx
* `EmptyState`):
*
* - first-time (the workspace has never started a chat) → educate. Tell
* the user what chat is for; don't surface starter prompts yet, they
* presume context the user doesn't have.
* - returning (at least one prior session exists) → starter prompts.
* Three taps, three common workflows; tapping prefills the composer
* draft so the user can edit before sending.
*
* Copy mirrors the web `chat.json` namespace 1:1. Mobile doesn't have
* i18n yet so the strings are inlined in English — when mobile adopts
* i18n the lookup keys (`empty_state.first_time_title` etc.) are already
* established on the web side, so the migration is a literal
* key-by-key swap.
*/
import { View } from "react-native";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
const STARTER_PROMPTS: { icon: string; text: string }[] = [
{ icon: "📋", text: "List my open issues by priority" },
{ icon: "📝", text: "Summarize what I did today" },
{ icon: "💡", text: "Help me plan what to do next" },
];
interface Props {
hasSessions: boolean;
agentName?: string;
onPickPrompt: (text: string) => void;
}
export function ChatEmptyState({ hasSessions, agentName, onPickPrompt }: Props) {
// First-time experience: educate before suggesting actions. Starter
// prompts here would presume the user already knows what chat is for.
if (!hasSessions) {
return (
<View className="flex-1 items-center justify-center px-6 py-8">
<View className="max-w-xs items-center gap-3">
<Text className="text-base font-semibold text-foreground text-center">
Chat with your agents
</Text>
<Text className="text-sm text-muted-foreground text-center">
<Text className="text-sm text-muted-foreground">
They know your workspace {" "}
</Text>
<Text className="text-sm font-medium text-foreground">
issues, projects, skills
</Text>
<Text className="text-sm text-muted-foreground">.</Text>
</Text>
<Text className="text-sm text-muted-foreground text-center">
Ask for a summary, plan your day, or hand off a small task.
</Text>
</View>
</View>
);
}
// Returning user: starter prompts are the fastest path back to action.
const title = agentName ? `Hi, I'm ${agentName}` : "Welcome back to Multica";
return (
<View className="flex-1 items-center justify-center px-6 py-8 gap-5">
<View className="items-center gap-1">
<Text className="text-base font-semibold text-foreground text-center">
{title}
</Text>
<Text className="text-sm text-muted-foreground text-center">
Try asking
</Text>
</View>
<View className="w-full max-w-xs gap-2">
{STARTER_PROMPTS.map((p) => (
<Button
key={p.text}
variant="outline"
onPress={() => onPickPrompt(p.text)}
className="h-auto justify-start px-3 py-2.5"
accessibilityLabel={p.text}
>
<Text className="text-sm text-foreground">
<Text className="text-sm">{p.icon} </Text>
{p.text}
</Text>
</Button>
))}
</View>
</View>
);
}

View File

@@ -0,0 +1,439 @@
/**
* Chat message list — user / assistant bubbles, oldest at top, newest at
* bottom. Initial render lands at the bottom; new arrivals auto-scroll
* when the user is anchored near the bottom; reading history is never
* yanked down.
*
* Behavioral parity (apps/mobile/CLAUDE.md):
* - Render ALL message roles. Unknown role values are downgraded to
* "assistant" by ChatMessageSchema's `.catch()`, so this list never
* needs to silently drop a row.
* - Render `failure_reason` messages with destructive styling — same
* boolean as web's destructive bubble + failureReasonLabel().
*
* v1 simplifications:
* - No "Replied in Ns" badge under assistant bubbles (elapsed_ms is
* parsed but not displayed). Easy v2 add — show below the bubble.
* - No attachment card rendering. Attachments embedded as
* `![](url)` / `[name](url)` in `content` flow through the existing
* markdown renderer.
*
* Interaction: long-press inside a bubble fires a native iOS
* `ActionSheetIOS` (Copy / Select Text / Cancel). While the sheet is on
* screen the targeted bubble's border highlights. The assistant branch
* has no border baseline because its bubble has no shell — adding a 2px
* baseline would shift layout per message. See `useChatMessageLongPress`
* in `./message-long-press.tsx`.
*
* List engine: FlashList v2 (Shopify). FlatList was the original choice
* (per the now-outdated "no FlashList" baseline in apps/mobile/CLAUDE.md
* — written before FlashList v2 stabilised). FlatList's `scrollToEnd` is
* janky on variable-height lists by RN's own docs admission, and our
* markdown bubbles render in multiple async passes (Shiki highlight,
* image natural-size, lightbox provider injection) — each pass used to
* fire onContentSizeChange and trigger another forced scroll, causing
* the "open chat → feels stuck" jank. FlashList v2 replaces the manual
* scroll dance with `maintainVisibleContentPosition`
* (default-on; locks visible item across content changes) +
* `startRenderingFromBottom` (initial paint at bottom, no setTimeout
* hacks). Cell recycling also keeps scroll-up smooth.
*/
import { ActivityIndicator, Pressable, View } from "react-native";
import { FlashList } from "@shopify/flash-list";
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import type {
ChatMessage,
ChatPendingTask,
TaskMessagePayload,
} from "@multica/core/types";
import type { AgentAvailability } from "@multica/core/agents";
import { taskMessagesOptions } from "@/data/queries/chat";
import { Text } from "@/components/ui/text";
import { Markdown } from "@/lib/markdown";
import { failureReasonLabel } from "@/lib/failure-reason-label";
import { formatElapsedMs } from "@/lib/format-elapsed";
import { cn } from "@/lib/utils";
import { useChatSelectStore } from "@/data/chat-select-store";
import { useChatMessageLongPress } from "./message-long-press";
import { ChatEmptyState } from "./chat-empty-state";
import { ChatTimeline } from "./chat-timeline";
import { StatusPill } from "./status-pill";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface Props {
messages: ChatMessage[];
loading: boolean;
/** Has the workspace ever started a chat? Drives empty-state copy. */
hasSessions: boolean;
/** Currently picked / inherited agent's display name. */
agentName?: string;
/** Receive a starter-prompt tap. Caller writes into the draft store
* (or focuses the composer with the text) — empty state stays neutral
* about send vs. preview. */
onPickPrompt: (text: string) => void;
/** Server-authoritative pending-task snapshot for the active session.
* Used to render the live timeline + status line as the last item in
* the message stream, mirroring web's
* `packages/views/chat/components/chat-message-list.tsx` placement. */
pendingTask?: ChatPendingTask | null;
/** Live timeline rows for the in-flight task. Already fetched by the
* parent so this list doesn't have to manage its own subscription. */
liveTaskMessages?: TaskMessagePayload[];
/** Resolved availability — drives the StatusPill's "Offline" /
* "Reconnecting" stages. Pass `undefined` while loading. */
availability?: AgentAvailability;
}
export function ChatMessageList({
messages,
loading,
hasSessions,
agentName,
onPickPrompt,
pendingTask,
liveTaskMessages,
availability,
}: Props) {
// Top-level selection subscription gates the outer "tap-outside-to-dismiss"
// Pressable below. When null, the Pressable stays disabled and every tap
// passes through to the list cells / bubble long-press wrappers normally.
const selectingId = useChatSelectStore((s) => s.selectingId);
if (loading && messages.length === 0) {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
);
}
if (messages.length === 0) {
// Empty new-chat state. Lives here (rather than the parent screen) so
// the empty state and the rendered list share spacing/layout rules.
return (
<ChatEmptyState
hasSessions={hasSessions}
agentName={agentName}
onPickPrompt={onPickPrompt}
/>
);
}
// Show the live trace + status line until the persisted assistant
// message lands. Once chat:done writes the assistant row, AssistantRow's
// own timeline (read from the same cache entry) owns the render — no
// double-rendering.
const pendingTaskId = pendingTask?.task_id ?? null;
const pendingAlreadyPersisted =
!!pendingTaskId &&
messages.some(
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
);
const showLiveSection = !!pendingTaskId && !pendingAlreadyPersisted;
const showLiveTimeline =
showLiveSection && (liveTaskMessages?.length ?? 0) > 0;
return (
// Outer Pressable owns the "tap anywhere outside the selected bubble
// to exit text-selection mode" gesture. Disabled when no message is
// selected, so it's a layout-only wrapper and every tap passes straight
// through to the FlashList cells. Active state captures any tap that
// didn't fire an inner Pressable — bubble cells in selecting mode
// render their body without a Pressable wrapper (see `MessageRow`'s
// `if (isSelecting) return body;`), so taps on the selected bubble
// also dismiss, matching iOS Notes / iMessage behaviour. Scroll
// gestures are unaffected (Pressable only intercepts non-drag taps).
<Pressable
onPress={
selectingId
? () => useChatSelectStore.getState().clear()
: undefined
}
disabled={!selectingId}
style={{ flex: 1 }}
>
{/* `key` on first message id forces remount on session switch so
`startRenderingFromBottom` re-fires and we land at the new
session's bottom (instead of inheriting the previous session's
scroll position). Cheap because sessions are switched, not
re-rendered every keystroke. */}
<FlashList
key={messages[0]?.id ?? "empty"}
data={messages}
keyExtractor={(m) => m.id}
renderItem={({ item }) => <MessageRow message={item} />}
ItemSeparatorComponent={MessageSeparator}
ListFooterComponent={
showLiveSection ? (
<View style={{ paddingTop: 12 }} className="gap-2">
{showLiveTimeline ? (
<ChatTimeline items={liveTaskMessages ?? []} isStreaming />
) : null}
<StatusPill
pendingTask={pendingTask}
taskMessages={liveTaskMessages}
availability={availability}
/>
</View>
) : null
}
// Outer padding mirrors web's max-w-4xl px-5 py-4 container at
// mobile scale. Vertical gap between bubbles handled by
// ItemSeparatorComponent (FlashList doesn't honour `gap-*` on
// contentContainer the way FlatList's gap-via-NativeWind did).
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 16,
}}
// Chat behavior: initial render at the bottom; when new messages
// arrive AND the user is within 20% of the bottom, auto-scroll.
// Reading history (further than 20% up) is preserved. This single
// prop replaces the entire FlatList-era guard ref dance.
maintainVisibleContentPosition={{
autoscrollToBottomThreshold: 0.2,
startRenderingFromBottom: true,
}}
// Any user-initiated scroll exits message text-selection mode —
// matches iMessage's behavior where scrolling implicitly commits /
// dismisses the selection caret. Hooks both drag-start and the
// momentum kick after a flick so a fast scroll can't escape.
onScrollBeginDrag={() => useChatSelectStore.getState().clear()}
onMomentumScrollBegin={() => useChatSelectStore.getState().clear()}
// iMessage-style keyboard dismissal: dragging the list pulls the
// keyboard down with the finger (iOS); tapping empty space between
// bubbles dismisses it. `handled` keeps Pressables inside bubbles
// (long-press action sheet etc.) firing normally.
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
/>
</Pressable>
);
}
function MessageSeparator() {
return <View style={{ height: 12 }} />;
}
function MessageRow({ message }: { message: ChatMessage }) {
const isUser = message.role === "user";
const isFailure = !!message.failure_reason;
const isSelecting = useChatSelectStore(
(s) => s.selectingId === message.id,
);
const longPress = useChatMessageLongPress(message);
if (isFailure) {
return (
<FailureBubble
reasonLabel={failureReasonLabel(message.failure_reason)}
rawError={message.content}
elapsedMs={message.elapsed_ms ?? null}
isSelecting={isSelecting}
longPress={longPress}
/>
);
}
if (isUser) {
// User bubble: same Markdown pipeline as assistant — `@mention`
// serialisation `[MUL-1](mention://issue/<id>)`, inline links, and
// inline code resolve identically to web's
// `packages/views/chat/components/chat-message-list.tsx` user branch.
// Width is capped at 80% so the bubble keeps the iMessage-style
// trailing alignment instead of stretching across the column.
const body = (
<View
className={cn(
"self-end max-w-[80%] rounded-2xl border-2 px-3.5 py-2 transition-colors",
isSelecting
? "bg-primary/5 border-primary/30"
: longPress.isPressed
? "bg-muted border-primary/30"
: "bg-muted border-transparent",
)}
>
<Markdown
content={message.content}
attachments={message.attachments}
selectable={isSelecting}
compact
/>
</View>
);
if (isSelecting) return body;
return (
<Pressable
onLongPress={longPress.onLongPress}
delayLongPress={500}
>
{body}
</Pressable>
);
}
// Assistant: timeline fold + markdown + elapsed caption. See
// AssistantRow for why timeline is lifted into its own component.
return (
<AssistantRow
message={message}
isSelecting={isSelecting}
longPress={longPress}
/>
);
}
/**
* Persisted assistant message. Renders:
*
* - Process-steps fold (from `task-messages` cache; same cache fed by
* the live timeline above, so completed runs keep their trace).
* - Markdown content (the model's final answer).
* - "Replied in Ns" caption when `elapsed_ms` is stamped.
*
* Web's equivalent is `AssistantMessage` in packages/views/chat/components/
* chat-message-list.tsx — same shape, simplified for RN (no inner Tooltip
* / Copy button — long-press already exposes Copy via the native action
* sheet, and selection mode owns the highlight, so a hover-only Copy
* affordance would be redundant on mobile).
*/
function AssistantRow({
message,
isSelecting,
longPress,
}: {
message: ChatMessage;
isSelecting: boolean;
longPress: ReturnType<typeof useChatMessageLongPress>;
}) {
// Read the cached timeline if any. `enabled` (in taskMessagesOptions) is
// gated on isTaskMessageTaskId — optimistic id prefixes never fetch, so
// freshly-sent messages don't spam the API while waiting for the real
// task_id to land. Cached cells (after live timeline finished) return
// synchronously with no network roundtrip.
const { data: timeline = [] } = useQuery(
taskMessagesOptions(message.task_id),
);
const body = (
<View className="gap-1.5">
{timeline.length > 0 ? (
<ChatTimeline items={timeline} />
) : null}
<Markdown
content={message.content}
attachments={message.attachments}
selectable={isSelecting}
/>
{message.elapsed_ms != null ? (
<ElapsedCaption variant="replied" elapsedMs={message.elapsed_ms} />
) : null}
</View>
);
if (isSelecting) return body;
return (
<Pressable onLongPress={longPress.onLongPress} delayLongPress={500}>
{body}
</Pressable>
);
}
// Persistent caption rendered under the assistant bubble / failure bubble
// once the server has written `elapsed_ms`. Server computes once at task
// completion, so this caption is identical across reloads and clients.
function ElapsedCaption({
variant,
elapsedMs,
}: {
variant: "replied" | "failed";
elapsedMs: number;
}) {
const label =
variant === "replied"
? `Replied in ${formatElapsedMs(elapsedMs)}`
: `Failed after ${formatElapsedMs(elapsedMs)}`;
return (
<Text className="text-xs text-muted-foreground/80 mt-1">{label}</Text>
);
}
function FailureBubble({
reasonLabel,
rawError,
elapsedMs,
isSelecting,
longPress,
}: {
reasonLabel: string;
rawError: string;
elapsedMs: number | null;
isSelecting: boolean;
longPress: ReturnType<typeof useChatMessageLongPress>;
}) {
const hasRawError = rawError.trim().length > 0;
// B6: pass `selectable={isSelecting}` rather than hard-coding
// `selectable` — otherwise UIKit's text-selection gesture pre-empts
// our long-press handler and the action sheet never fires. Select-mode
// cue is the border-tint to primary; bg stays destructive so the
// failure signal is never lost.
const body = (
<View className="self-start max-w-[80%]">
<View
className={cn(
"rounded-2xl border-2 bg-destructive/10 px-3.5 py-2 transition-colors",
isSelecting || longPress.isPressed
? "border-primary/30"
: "border-destructive/30",
)}
>
<Text className="text-xs font-semibold text-destructive">
{reasonLabel}
</Text>
{hasRawError ? (
<Collapsible>
<CollapsibleTrigger asChild>
<View
accessibilityRole="button"
accessibilityLabel="Show error details"
className="mt-1 flex-row items-center gap-1 active:opacity-70"
>
<Ionicons
name="chevron-forward"
size={12}
color="#71717a"
/>
<Text className="text-xs text-muted-foreground">
Show details
</Text>
</View>
</CollapsibleTrigger>
<CollapsibleContent>
<View className="mt-1 rounded bg-muted/40 px-2 py-1.5">
<Text
className="text-xs text-muted-foreground"
selectable={isSelecting}
>
{rawError}
</Text>
</View>
</CollapsibleContent>
</Collapsible>
) : null}
</View>
{elapsedMs != null ? (
<ElapsedCaption variant="failed" elapsedMs={elapsedMs} />
) : null}
</View>
);
if (isSelecting) return body;
return (
<Pressable onLongPress={longPress.onLongPress} delayLongPress={500}>
{body}
</Pressable>
);
}

View File

@@ -0,0 +1,40 @@
/**
* Right-side actions for the Chat tab header. Two buttons:
* - ⋯ (session menu): only when an active session exists.
* - + (new chat): always shown.
*
* Both are RNR `<Button variant="ghost" size="icon">` via IconButton, so
* touch feedback / sizing / dark-mode tinting are all consistent with the
* rest of the header toolbar.
*/
import { IconButton } from "@/components/ui/icon-button";
interface Props {
showMore: boolean;
onMorePress: () => void;
onNewPress: () => void;
}
export function ChatSessionActions({
showMore,
onMorePress,
onNewPress,
}: Props) {
return (
<>
{showMore ? (
<IconButton
name="ellipsis-horizontal"
onPress={onMorePress}
accessibilityLabel="Session actions"
/>
) : null}
<IconButton
name="add"
iconSize={24}
onPress={onNewPress}
accessibilityLabel="New chat"
/>
</>
);
}

View File

@@ -0,0 +1,268 @@
/**
* Per-task execution trace — what the agent is/was thinking and which tools
* it called. Rendered:
*
* - Live (under the StatusPill while a task is in flight), AND
* - Persisted (under the assistant bubble once the message has landed)
*
* Process steps (thinking / tool_use / tool_result / error) collapse
* behind a single "N steps" toggle. Final text is NOT rendered here —
* the parent renders the assistant message's `content` (or the latest
* streaming text) as its own markdown block.
*
* Folds use RNR `Collapsible` (built on `@rn-primitives/collapsible`).
* The earlier version of this file hand-rolled four separate
* `useState + Pressable + chevron` triggers (~60 lines of state +
* handlers); Collapsible owns open/close + a11y semantics in one place.
*
* `defaultOpen` is true on the outer fold while streaming so the user
* sees activity; the persisted instance below an assistant bubble
* starts closed (matches web's `OuterProcessFold` behaviour in
* `packages/views/chat/components/chat-message-list.tsx`).
*/
import { View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { TaskMessagePayload } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface Props {
items: TaskMessagePayload[];
/** Whether the owning task is still running. Drives the default-open
* state and the dot-pulse next to the trigger. */
isStreaming?: boolean;
}
export function ChatTimeline({ items, isStreaming = false }: Props) {
const processSteps = items.filter((i) => i.type !== "text");
if (processSteps.length === 0) return null;
return (
<Collapsible defaultOpen={isStreaming}>
<CollapsibleTrigger asChild>
<View
accessibilityRole="button"
accessibilityLabel={`${processSteps.length} step${processSteps.length === 1 ? "" : "s"}`}
className="flex-row items-center gap-1 active:opacity-70"
>
<Ionicons name="chevron-forward" size={12} color="#71717a" />
{isStreaming ? <StreamingDot /> : null}
<Text className="text-xs text-muted-foreground">
{processSteps.length === 1
? "1 step"
: `${processSteps.length} steps`}
</Text>
</View>
</CollapsibleTrigger>
<CollapsibleContent>
<View className="mt-1 rounded-lg border border-border bg-muted/20 px-2 py-1.5 gap-0.5">
{processSteps.map((item) => (
<StepRow key={`${item.task_id}-${item.seq}`} item={item} />
))}
</View>
</CollapsibleContent>
</Collapsible>
);
}
function StreamingDot() {
// Single accent dot beside the trigger so the user knows the rows
// below may still be growing. Real "agent is alive" cue is StatusPill
// (breathing dots) above; this is a quiet co-signal.
return <View className="h-1.5 w-1.5 rounded-full bg-primary" />;
}
function StepRow({ item }: { item: TaskMessagePayload }) {
switch (item.type) {
case "thinking":
return <ThinkingRow item={item} />;
case "tool_use":
return <ToolCallRow item={item} />;
case "tool_result":
return <ToolResultRow item={item} />;
case "error":
return <ErrorRow item={item} />;
default:
return null;
}
}
function ThinkingRow({ item }: { item: TaskMessagePayload }) {
const text = item.content ?? "";
if (!text) return null;
const preview = text.length > 80 ? `${text.slice(0, 80)}` : text;
return (
<Collapsible>
<CollapsibleTrigger asChild>
<View className="py-0.5 flex-row items-start gap-1.5 active:opacity-70">
<Ionicons
name="bulb-outline"
size={12}
color="#a1a1aa"
style={{ marginTop: 2 }}
/>
<Text
className="flex-1 text-xs italic text-muted-foreground"
numberOfLines={1}
>
{preview}
</Text>
</View>
</CollapsibleTrigger>
<CollapsibleContent>
<Text className="ml-4 mt-0.5 text-xs italic text-muted-foreground">
{text}
</Text>
</CollapsibleContent>
</Collapsible>
);
}
function ToolCallRow({ item }: { item: TaskMessagePayload }) {
const summary = getToolSummary(item);
const hasInput = !!item.input && Object.keys(item.input).length > 0;
// If the call has no expandable input, render a non-interactive row —
// wrapping a static row in Collapsible adds a wasted tap target.
if (!hasInput) {
return (
<View className="py-0.5 flex-row items-center gap-1.5">
<View style={{ width: 12 }} />
<Text className="text-xs font-medium text-foreground">
{item.tool ?? "tool"}
</Text>
{summary ? (
<Text
className="flex-1 text-xs text-muted-foreground"
numberOfLines={1}
>
{summary}
</Text>
) : null}
</View>
);
}
return (
<Collapsible>
<CollapsibleTrigger asChild>
<View className="py-0.5 flex-row items-center gap-1.5 active:opacity-70">
<Ionicons name="chevron-forward" size={12} color="#71717a" />
<Text className="text-xs font-medium text-foreground">
{item.tool ?? "tool"}
</Text>
{summary ? (
<Text
className="flex-1 text-xs text-muted-foreground"
numberOfLines={1}
>
{summary}
</Text>
) : null}
</View>
</CollapsibleTrigger>
<CollapsibleContent>
<View className="ml-4 mt-1 rounded bg-muted/40 px-2 py-1.5">
<Text className="text-xs text-muted-foreground">
{JSON.stringify(item.input, null, 2)}
</Text>
</View>
</CollapsibleContent>
</Collapsible>
);
}
function ToolResultRow({ item }: { item: TaskMessagePayload }) {
const output = item.output ?? "";
if (!output) return null;
const preview = output.length > 80 ? `${output.slice(0, 80)}` : output;
const prefix = item.tool ? `${item.tool} result: ` : "result: ";
return (
<Collapsible>
<CollapsibleTrigger asChild>
<View className="py-0.5 flex-row items-start gap-1.5 active:opacity-70">
<Ionicons
name="chevron-forward"
size={12}
color="#71717a"
style={{ marginTop: 2 }}
/>
<Text
className="flex-1 text-xs text-muted-foreground/80"
numberOfLines={1}
>
<Text className="text-xs text-muted-foreground">{prefix}</Text>
{preview}
</Text>
</View>
</CollapsibleTrigger>
<CollapsibleContent>
<View className="ml-4 mt-1 rounded bg-muted/40 px-2 py-1.5">
<Text className="text-xs text-muted-foreground">
{output.length > 4000
? `${output.slice(0, 4000)}\n…(truncated)`
: output}
</Text>
</View>
</CollapsibleContent>
</Collapsible>
);
}
function ErrorRow({ item }: { item: TaskMessagePayload }) {
return (
<View className="py-0.5 flex-row items-start gap-1.5">
<Ionicons
name="alert-circle"
size={12}
color="#dc2626"
style={{ marginTop: 2 }}
/>
<Text className="flex-1 text-xs text-destructive" numberOfLines={3}>
{item.content}
</Text>
</View>
);
}
/**
* Mirror of web's `getToolSummary` (chat-message-list.tsx) — picks the most
* informative single-line summary from a tool_use payload. Order matters:
* `query` / `file_path` / `pattern` are the headline params, `command` /
* `prompt` get truncated, and a final loop catches whichever short string
* a future tool might emit.
*/
function getToolSummary(item: TaskMessagePayload): string {
if (!item.input) return "";
const inp = item.input as Record<string, unknown>;
const pick = (k: string): string | undefined => {
const v = inp[k];
return typeof v === "string" && v.length > 0 ? v : undefined;
};
const q = pick("query");
if (q) return q;
const fp = pick("file_path") ?? pick("path");
if (fp) return shortenPath(fp);
const p = pick("pattern");
if (p) return p;
const d = pick("description");
if (d) return d;
const cmd = pick("command");
if (cmd) return cmd.length > 100 ? `${cmd.slice(0, 100)}` : cmd;
const prompt = pick("prompt");
if (prompt) return prompt.length > 100 ? `${prompt.slice(0, 100)}` : prompt;
const skill = pick("skill");
if (skill) return skill;
for (const v of Object.values(inp)) {
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
}
return "";
}
function shortenPath(p: string): string {
const parts = p.split("/");
if (parts.length <= 3) return p;
return `…/${parts.slice(-2).join("/")}`;
}

View File

@@ -0,0 +1,59 @@
/**
* Centred, tappable title region for the Chat tab's native Stack header.
* Rendered as `headerTitle: () => <ChatTitleButton ... />` so iOS positions
* it where it expects the screen title, but the whole region is a Pressable
* — tap opens the sessions + agent picker sheet.
*/
import { Pressable, View } from "react-native";
import type { Agent, ChatSession } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
interface Props {
currentSession: ChatSession | null;
currentAgent: Agent | null;
onPress: () => void;
}
export function ChatTitleButton({
currentSession,
currentAgent,
onPress,
}: Props) {
const agentName = currentAgent?.name ?? "Chat";
const subtitle = currentSession?.title || "New chat";
return (
<Pressable
onPress={onPress}
hitSlop={4}
className="flex-row items-center gap-2 px-2 py-1 rounded-lg active:bg-secondary"
accessibilityRole="button"
accessibilityLabel="Sessions and agent picker"
>
<ActorAvatar
type={currentAgent ? "agent" : null}
id={currentAgent?.id ?? null}
size={24}
showPresence
/>
<View>
<View className="flex-row items-center gap-1">
<Text
className="text-base font-semibold text-foreground"
numberOfLines={1}
>
{agentName}
</Text>
<Text className="text-xs text-muted-foreground"></Text>
</View>
<Text
className="text-xs text-muted-foreground"
numberOfLines={1}
>
{subtitle}
</Text>
</View>
</Pressable>
);
}

View File

@@ -0,0 +1,81 @@
/**
* Long-press handler for a chat message bubble. Exposes `onLongPress`
* (drives a native iOS ActionSheetIOS) and `isPressed` (drives the
* caller's highlight ring while the sheet is on screen).
*
* iOS-native first per apps/mobile/CLAUDE.md §UI components → waterfall
* step 1: `ActionSheetIOS.showActionSheetWithOptions`. Zero custom
* layout, zero animation, zero overflow math, zero new deps.
*
* Item set (v1, conditional):
* Copy · Select Text · Cancel
*
* Mirrors `useCommentLongPress` in `components/issue/comment-context-
* menu.tsx` — kept as a sibling rather than a shared primitive because
* we have only 2 callers (chat + comments). Below the "3 callers + no
* native alternative" threshold in apps/mobile/CLAUDE.md.
*/
import { useCallback, useState } from "react";
import { ActionSheetIOS } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import type { ChatMessage } from "@multica/core/types";
import { useChatSelectStore } from "@/data/chat-select-store";
export function useChatMessageLongPress(
message: ChatMessage,
): { onLongPress: () => void; isPressed: boolean } {
const [isPressed, setIsPressed] = useState(false);
const onLongPress = useCallback(() => {
const hasContent = !!message.content;
Haptics.selectionAsync().catch(() => {});
setIsPressed(true);
type Action =
| { kind: "copy" }
| { kind: "select" }
| { kind: "cancel" };
const options: string[] = [];
const actions: Action[] = [];
const push = (label: string, action: Action) => {
options.push(label);
actions.push(action);
};
if (hasContent) {
push("Copy", { kind: "copy" });
push("Select Text", { kind: "select" });
}
push("Cancel", { kind: "cancel" });
const cancelButtonIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{ options, cancelButtonIndex },
(i) => {
setIsPressed(false);
const action = actions[i];
if (!action || action.kind === "cancel") return;
switch (action.kind) {
case "copy":
if (message.content) {
Clipboard.setStringAsync(message.content);
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success,
).catch(() => {});
}
return;
case "select":
useChatSelectStore.getState().setSelecting(message.id);
return;
}
},
);
}, [message]);
return { onLongPress, isPressed };
}

View File

@@ -0,0 +1,37 @@
/**
* Banner shown when the workspace has zero usable agents for the current
* user. Mirrors the role of packages/views/chat/components/no-agent-banner.tsx
* on web — distinct visual cue + a route into the place where users can
* add agents.
*
* Rendered just under ChatHeader. Tap → More → Agents.
*/
import { Pressable } from "react-native";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import { useWorkspaceStore } from "@/data/workspace-store";
export function NoAgentBanner() {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const handlePress = () => {
if (!wsSlug) return;
router.push(`/${wsSlug}/more/agents`);
};
return (
<Pressable
onPress={handlePress}
className="mx-3 mt-2 mb-1 rounded-xl border border-border bg-secondary/50 px-3 py-2 active:opacity-80"
accessibilityRole="button"
accessibilityLabel="No agents available, open agents settings"
>
<Text className="text-sm font-medium text-foreground">
No agents available
</Text>
<Text className="text-xs text-muted-foreground mt-0.5">
Add or enable an agent in More Agents to start chatting.
</Text>
</Pressable>
);
}

View File

@@ -0,0 +1,60 @@
/**
* Inline notice rendered above the chat input when the active agent's
* runtime isn't reachable. Mirror of
* `packages/views/chat/components/offline-banner.tsx`.
*
* Two states render copy:
* - `unstable` (runtime offline < 5 min) → amber, "may reconnect"
* - `offline` (runtime offline ≥ 5 min) → muted, "won't run until back"
*
* Loading silence: `undefined` and the implicit "online" case render nothing.
* The chat composer never sees a speculative offline flash during the cold
* fetch window — copy only appears when there's a real-world implication for
* the message the user is about to send.
*/
import { Ionicons } from "@expo/vector-icons";
import { View } from "react-native";
import type { AgentAvailability } from "@multica/core/agents";
import { Text } from "@/components/ui/text";
interface Props {
/** Display name for the copy. */
agentName?: string;
/**
* Resolved presence availability. Pass `undefined` to suppress the banner
* — we only surface known offline / unstable states, never speculative
* copy during loading.
*/
availability: AgentAvailability | undefined;
}
export function OfflineBanner({ agentName, availability }: Props) {
if (availability !== "offline" && availability !== "unstable") return null;
const name = agentName?.trim() || "This agent";
if (availability === "unstable") {
return (
<View className="mx-3 mb-1.5 flex-row items-center gap-1.5 rounded-md bg-warning/15 px-2.5 py-1.5">
<Ionicons name="alert-circle-outline" size={14} color="#a16207" />
<Text
className="flex-1 text-xs text-warning"
numberOfLines={1}
>
{name} may have just disconnected your message will queue.
</Text>
</View>
);
}
return (
<View className="mx-3 mb-1.5 flex-row items-center gap-1.5 rounded-md bg-muted px-2.5 py-1.5">
<Ionicons name="cloud-offline-outline" size={14} color="#71717a" />
<Text
className="flex-1 text-xs text-muted-foreground"
numberOfLines={1}
>
{name} is offline. Messages will wait until its runtime is back.
</Text>
</View>
);
}

View File

@@ -0,0 +1,240 @@
/**
* In-flight task status — mobile-side mirror of
* `packages/views/chat/components/task-status-pill.tsx`.
*
* Visual choices match web's intent ("diagnostic inline text, not a
* notification chip") adapted for RN:
*
* - No chrome. No border, no background, no rounded-full pill. Just a
* line of muted text that lives at the end of the message stream.
* - "Breathing dots" instead of CSS shimmer. RN can't do
* `background-clip: text` gradient sweeps (web's
* `animate-chat-text-shimmer`), so we use the next-best activity cue:
* three small dots fading in/out with a staggered phase. Same
* "AI is alive" signal as iMessage's typing dots / ChatGPT iOS's
* thinking indicator.
* - No Stop button inline. The composer already swaps Send → Stop
* while `sending===true` (chat-composer.tsx). A second Stop here
* was redundant chrome.
*
* Stage logic (queued / dispatched / running × taskMessages → stage label)
* mirrors web's `pickStageKeys` exactly — same priority order, same
* fallback. Differences are visual-only.
*/
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
type SharedValue,
} from "react-native-reanimated";
import type {
ChatPendingTask,
TaskMessagePayload,
} from "@multica/core/types";
import type { AgentAvailability } from "@multica/core/agents";
import { Text } from "@/components/ui/text";
import { formatElapsedSecs } from "@/lib/format-elapsed";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
interface Props {
pendingTask: ChatPendingTask | null | undefined;
taskMessages?: readonly TaskMessagePayload[];
/** Resolved presence; pass `undefined` to suppress availability hints
* during loading so the line never flashes "Offline" speculatively. */
availability?: AgentAvailability;
}
interface Stage {
label: string;
/** True for static labels (e.g. "Offline") where the breathing dots
* shouldn't animate — there's nothing for the user to wait on. */
static?: boolean;
}
const TOOL_LABELS: Record<string, string> = {
bash: "Running command",
exec: "Running command",
read: "Reading files",
glob: "Reading files",
grep: "Searching code",
write: "Making edits",
edit: "Making edits",
multi_edit: "Making edits",
multiedit: "Making edits",
web_search: "Searching web",
websearch: "Searching web",
};
function pickStage(
status: string | undefined,
taskMessages: readonly TaskMessagePayload[],
availability: AgentAvailability | undefined,
): Stage {
if (
(status === "queued" || status === "dispatched") &&
availability === "offline"
) {
return { label: "Offline", static: true };
}
if (
(status === "queued" || status === "dispatched") &&
availability === "unstable"
) {
return { label: "Reconnecting" };
}
if (status === "queued") return { label: "Queued" };
if (status === "dispatched") return { label: "Starting up" };
let latest: TaskMessagePayload | null = null;
for (let i = taskMessages.length - 1; i >= 0; i--) {
const m = taskMessages[i];
if (m && m.type !== "error" && m.type !== "tool_result") {
latest = m;
break;
}
}
if (!latest) return { label: "Thinking" };
if (latest.type === "thinking") return { label: "Thinking" };
if (latest.type === "text") return { label: "Typing" };
if (latest.type === "tool_use") {
const slug = (latest.tool ?? "").toLowerCase();
return { label: TOOL_LABELS[slug] ?? "Working" };
}
return { label: "Thinking" };
}
export function StatusPill({
pendingTask,
taskMessages = [],
availability,
}: Props) {
const taskId = pendingTask?.task_id;
const createdAt = pendingTask?.created_at;
// Anchor — locked per task. Reset on task_id change so a new run
// restarts the timer from 0; mid-run we never reassign, otherwise the
// counter would visibly snap backwards when a server `created_at`
// arrives a few hundred ms before the optimistic `Date.now()` anchor.
// (Stored in a `useEffect`-driven mutable; useRef would also work but
// we already touch state on tick, so a tiny extra hook is fine.)
const anchorMs = useTaskAnchor(taskId, createdAt);
// 1Hz tick — the only reason this hook exists is to force a re-render
// every second. We don't read the tick value; we read Date.now() at
// render time.
useTick(!!taskId, 1000);
if (!taskId) return null;
const status =
taskMessages.length > 0 ? "running" : pendingTask?.status;
const elapsedSec = Math.max(0, Math.floor((Date.now() - anchorMs) / 1000));
const stage = pickStage(status, taskMessages, availability);
return (
<View
className="flex-row items-center gap-1.5 px-1"
accessibilityLiveRegion="polite"
>
{stage.static ? null : <BreathingDots />}
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{stage.label}
<Text className="text-xs text-muted-foreground/70">
{" · "}
{formatElapsedSecs(elapsedSec)}
</Text>
</Text>
</View>
);
}
// ─── helpers ──────────────────────────────────────────────────────────────
function useTaskAnchor(
taskId: string | undefined,
createdAt: string | undefined,
): number {
const ref = useRef<{ id: string | undefined; ms: number }>({
id: undefined,
ms: Date.now(),
});
if (ref.current.id !== taskId) {
const t = createdAt ? Date.parse(createdAt) : NaN;
ref.current = {
id: taskId,
ms: Number.isFinite(t) ? t : Date.now(),
};
}
return ref.current.ms;
}
function useTick(enabled: boolean, intervalMs: number) {
const [, setN] = useState(0);
useEffect(() => {
if (!enabled) return;
const id = setInterval(() => setN((n) => n + 1), intervalMs);
return () => clearInterval(id);
}, [enabled, intervalMs]);
}
// Three small dots, fading in/out on a staggered phase — same "in
// progress" affordance iMessage uses for typing indicators. Each dot
// owns its own SharedValue; the second and third are kicked off via
// setTimeout (150ms / 300ms) so the wave reads as motion rather than
// flicker.
function BreathingDots() {
const { colorScheme } = useColorScheme();
const tint = THEME[colorScheme].mutedForeground;
const d1 = useSharedValue(0.3);
const d2 = useSharedValue(0.3);
const d3 = useSharedValue(0.3);
useEffect(() => {
const start = (v: SharedValue<number>) => {
v.value = withRepeat(
withSequence(
withTiming(1, { duration: 400 }),
withTiming(0.3, { duration: 400 }),
),
-1,
);
};
start(d1);
const t2 = setTimeout(() => start(d2), 150);
const t3 = setTimeout(() => start(d3), 300);
return () => {
clearTimeout(t2);
clearTimeout(t3);
cancelAnimation(d1);
cancelAnimation(d2);
cancelAnimation(d3);
};
}, [d1, d2, d3]);
const s1 = useAnimatedStyle(() => ({ opacity: d1.value }));
const s2 = useAnimatedStyle(() => ({ opacity: d2.value }));
const s3 = useAnimatedStyle(() => ({ opacity: d3.value }));
return (
<View className="flex-row items-center gap-0.5">
<Animated.View
style={[s1, { backgroundColor: tint }]}
className="h-1 w-1 rounded-full"
/>
<Animated.View
style={[s2, { backgroundColor: tint }]}
className="h-1 w-1 rounded-full"
/>
<Animated.View
style={[s3, { backgroundColor: tint }]}
className="h-1 w-1 rounded-full"
/>
</View>
);
}

View File

@@ -0,0 +1,609 @@
/**
* Shared message composer used by both the issue-comment thread and the
* chat tab. Two visual states:
*
* collapsed → pill button (configurable label / icon). Minimal vertical
* footprint so the list above gets the full screen.
* expanded → optional reply chip → chip row (@ + image + file) →
* plain TextInput → toolbar (`@ 📷 📎 ──── [➤ or Stop]`).
*
* Mentions / images / files all live in the chip row OUTSIDE the text
* input. The input itself is a plain RN `<TextInput multiline>` — no
* controlled selection, no inline overlays. On submit the composer
* prepends mention markdown links to the typed text and attaches
* `attachmentIds`. Server-side mention regex
* (`server/internal/util/mention.go:16`) parses them as if they were
* inline.
*
* Mention picker is a formSheet route, pushed via `mentionPickerPath`.
* That route writes selections into `useMentionDraftStore`; this composer
* reads from the same store.
*
* Why a shared component:
* - Comment and chat composers want byte-identical UI / interaction.
* - Chat-specific differences are slim: controlled draft text (parent
* owns the value for cross-session persistence), Stop button during
* agent execution. Both addressed via optional props.
*
* What this component does NOT own:
* - The submit action — `onSubmit` is the caller's escape hatch (it
* wires `useCreateComment` on the comment side, the chat send burst
* on the chat side).
* - Reply target lifecycle — comment passes in `replyTarget` +
* `onClearReplyTarget` from its store; chat doesn't.
* - Stop visual / animation — chat passes a `renderStop()` slot when
* `isSending` is true.
*
* Cleanup: mention draft store cleared on unmount so navigating away
* from comment-A's draft doesn't leak `@张三` into comment-B's composer.
*/
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Alert, Keyboard, Pressable, TextInput, View } from "react-native";
import { KeyboardStickyView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { router, type Href } from "expo-router";
import * as Haptics from "expo-haptics";
import * as ImagePicker from "expo-image-picker";
import * as DocumentPicker from "expo-document-picker";
import { api, MAX_FILE_SIZE } from "@/data/api";
import { useMentionDraftStore } from "@/data/stores/mention-draft-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { stripMarkdown } from "@/lib/strip-markdown";
import { THEME } from "@/lib/theme";
import { Text } from "@/components/ui/text";
import { IconButton } from "@/components/ui/icon-button";
import {
ComposerAttachmentRow,
type ComposerAttachmentItem,
type MentionChip,
} from "@/components/issue/composer-attachment-row";
export interface MessageComposerReplyTarget {
actorName: string;
preview: string;
}
interface Props {
/** Submit callback. Composer awaits this; on rejection it restores text,
* attachments, and mentions so the user can retry without losing
* context. Resolved promise → text + chips cleared, composer collapses
* back to pill. */
onSubmit: (args: {
content: string;
attachmentIds: string[];
mentions: MentionChip[];
}) => Promise<void>;
/** Push target for the `@` button. The picker route reads /
* writes `useMentionDraftStore` directly. */
mentionPickerPath: Href;
/** Attachment upload context — forwarded to `api.uploadFile`. Comment
* passes `issueId`; chat omits both (uploads are session-scoped via
* the message id assigned by the server post-send). */
uploadContext?: { issueId?: string; commentId?: string };
placeholder?: string;
pillLabel?: string;
pillIcon?: keyof typeof Ionicons.glyphMap;
/** Optional controlled-text mode. When `value` + `onChangeText` are
* both provided, the parent owns the draft (chat: persists to its
* draft store across sessions). When omitted, composer manages its
* own internal text state (comment). */
value?: string;
onChangeText?: (next: string) => void;
/** Optional reply chip (comment only). */
replyTarget?: MessageComposerReplyTarget | null;
onClearReplyTarget?: () => void;
/** Composer enters "auto-expanded + focused" mode when this changes to
* a truthy stable key. Comment uses it to react to long-press → reply
* flow. Chat doesn't pass it. */
expandTrigger?: string | null;
/** When `isSending` is true AND `renderStop` is provided, the trailing
* send button is replaced by whatever `renderStop` returns. Chat uses
* this to show a Stop affordance while the agent is running. */
isSending?: boolean;
renderStop?: () => ReactNode;
/** Hard-disable. Used when chat has no usable agent. The pill shows
* `disabledReason` instead of `pillLabel`, and the pill is
* non-interactive (cannot expand). */
disabled?: boolean;
disabledReason?: string;
/** When true the composer renders flush at the bottom of its parent
* WITHOUT the KeyboardStickyView keyboard-aware lift + safe-area
* inset. Chat's parent owns its own KeyboardAvoidingView and
* bottom-inset handling (chat.tsx), so the composer must not also
* apply them. Comment's parent does NOT handle keyboard, so the
* composer keeps the default `true`. */
manageKeyboard?: boolean;
}
function makeLocalId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** Serialises mention chips into the markdown link form the backend
* regex parser recognises. The string lands at the START of the
* outgoing content; mobile can't position mentions inline because the
* TextInput is plain. Acceptable semantic difference vs web/desktop's
* rich editor (web supports anywhere-in-text). */
function serializeMentions(chips: MentionChip[]): string {
return chips
.map((m) => {
const label =
m.type === "issue"
? m.name
: m.type === "all"
? "@all"
: `@${m.name}`;
return `[${label}](mention://${m.type}/${m.id})`;
})
.join(" ");
}
export function MessageComposer({
onSubmit,
mentionPickerPath,
uploadContext,
placeholder = "Type a message…",
pillLabel = "Type a message…",
pillIcon = "chatbubble-ellipses-outline",
value: controlledValue,
onChangeText: controlledOnChange,
replyTarget = null,
onClearReplyTarget,
expandTrigger,
isSending = false,
renderStop,
disabled = false,
disabledReason,
manageKeyboard = true,
}: Props) {
const { colorScheme } = useColorScheme();
const theme = THEME[colorScheme];
const insets = useSafeAreaInsets();
const inputRef = useRef<TextInput>(null);
const [expanded, setExpanded] = useState(false);
const [internalText, setInternalText] = useState("");
const [attachments, setAttachments] = useState<ComposerAttachmentItem[]>([]);
const [submitting, setSubmitting] = useState(false);
// Hybrid controlled / uncontrolled pattern (React-canonical). Chat
// passes `value`/`onChangeText` for cross-session draft persistence;
// comment omits both and the composer manages local state.
const isControlled =
controlledValue !== undefined && controlledOnChange !== undefined;
const text = isControlled ? controlledValue : internalText;
const setText = useCallback(
(next: string) => {
if (isControlled) {
controlledOnChange(next);
} else {
setInternalText(next);
}
},
[isControlled, controlledOnChange],
);
const mentions = useMentionDraftStore((s) => s.mentions);
const removeMention = useMentionDraftStore((s) => s.remove);
const clearMentions = useMentionDraftStore((s) => s.clear);
// Drop mention draft on composer unmount so navigating away doesn't
// leak chips into the next composer's session.
useEffect(() => {
return () => {
clearMentions();
};
}, [clearMentions]);
// Auto-expand + focus when an `expandTrigger` changes. Comment uses
// this to react to the long-press → reply flow setting a reply target.
const triggerSeen = useRef<string | null>(null);
if (
expandTrigger &&
triggerSeen.current !== expandTrigger &&
!disabled
) {
triggerSeen.current = expandTrigger;
setExpanded(true);
requestAnimationFrame(() => inputRef.current?.focus());
}
const hasInFlightUpload = attachments.some((a) => a.status === "uploading");
const canSend =
!disabled &&
!isSending &&
!submitting &&
!hasInFlightUpload &&
(text.trim().length > 0 || mentions.length > 0);
const expand = useCallback(() => {
if (disabled) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
setExpanded(true);
// Tapping the pill = "I want to write a new message". Drop any
// lingering reply target so a stale chip from a prior long-press →
// dismiss-without-send cycle doesn't bleed into the fresh draft.
onClearReplyTarget?.();
requestAnimationFrame(() => inputRef.current?.focus());
}, [disabled, onClearReplyTarget]);
const handleSubmit = useCallback(async () => {
if (!canSend) return;
const textSnap = text;
const mentionsSnap = mentions;
const attachmentsSnap = attachments;
const mentionMd = serializeMentions(mentionsSnap);
const trimmed = textSnap.trim();
const content = mentionMd
? trimmed
? `${mentionMd} ${trimmed}`
: mentionMd
: trimmed;
const activeIds = attachmentsSnap
.filter((a) => a.status === "completed")
.map((a) => a.id)
.filter((id): id is string => !!id);
// Optimistic clear: text + chips empty out immediately so the next
// typing tick doesn't double-include them. Restored on rejection.
setText("");
setAttachments([]);
clearMentions();
setSubmitting(true);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
try {
await onSubmit({
content,
attachmentIds: activeIds,
mentions: mentionsSnap,
});
// Success → fully exit composing mode. Explicit triple-step
// because a missing blur leaves the keyboard up; missing
// Keyboard.dismiss races on iOS when focus is in-flight; missing
// setExpanded(false) leaves the expanded card on screen.
inputRef.current?.blur();
Keyboard.dismiss();
setExpanded(false);
} catch {
setText(textSnap);
setAttachments(attachmentsSnap);
mentionsSnap.forEach((m) =>
useMentionDraftStore.getState().toggle(m),
);
} finally {
setSubmitting(false);
}
}, [
canSend,
text,
mentions,
attachments,
setText,
clearMentions,
onSubmit,
]);
/** Streams a picked asset to /api/upload-file, updating the matching
* thumbnail's status as it goes. Pulled out so retry can call it
* again without re-opening the picker. */
const startUpload = useCallback(
async (
localId: string,
asset: { uri: string; name: string; type: string },
) => {
try {
const result = await api.uploadFile(asset, uploadContext);
setAttachments((prev) =>
prev.map((it) =>
it.localId === localId
? {
...it,
status: "completed",
id: result.id,
url: result.url,
downloadUrl: result.download_url,
}
: it,
),
);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setAttachments((prev) =>
prev.map((it) =>
it.localId === localId
? { ...it, status: "failed", error: message }
: it,
),
);
}
},
[uploadContext],
);
const onImagePress = useCallback(async () => {
const picker = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 1,
});
if (picker.canceled) return;
const picked = picker.assets[0];
if (!picked) return;
if (picked.fileSize != null && picked.fileSize > MAX_FILE_SIZE) {
Alert.alert("File too large", "Files must be smaller than 100 MB.");
return;
}
const filename = picked.fileName ?? `image-${Date.now()}.jpg`;
const mimeType = picked.mimeType ?? "image/jpeg";
const localId = makeLocalId();
setAttachments((prev) => [
...prev,
{
localId,
localUri: picked.uri,
filename,
mimeType,
status: "uploading",
},
]);
requestAnimationFrame(() => inputRef.current?.focus());
await startUpload(localId, {
uri: picked.uri,
name: filename,
type: mimeType,
});
}, [startUpload]);
const onFilePress = useCallback(async () => {
const picker = await DocumentPicker.getDocumentAsync({
type: "*/*",
copyToCacheDirectory: true,
});
if (picker.canceled) return;
const picked = picker.assets[0];
if (!picked) return;
if (picked.size != null && picked.size > MAX_FILE_SIZE) {
Alert.alert("File too large", "Files must be smaller than 100 MB.");
return;
}
const mimeType = picked.mimeType ?? "application/octet-stream";
const localId = makeLocalId();
setAttachments((prev) => [
...prev,
{
localId,
localUri: picked.uri,
filename: picked.name,
mimeType,
status: "uploading",
},
]);
requestAnimationFrame(() => inputRef.current?.focus());
await startUpload(localId, {
uri: picked.uri,
name: picked.name,
type: mimeType,
});
}, [startUpload]);
const onRemoveAttachment = useCallback((localId: string) => {
setAttachments((prev) => prev.filter((it) => it.localId !== localId));
}, []);
const onRetryAttachment = useCallback(
(localId: string) => {
const item = attachments.find((it) => it.localId === localId);
if (!item) return;
setAttachments((prev) =>
prev.map((it) =>
it.localId === localId
? { ...it, status: "uploading", error: undefined }
: it,
),
);
void startUpload(localId, {
uri: item.localUri,
name: item.filename,
type: item.mimeType,
});
},
[attachments, startUpload],
);
const onAtPress = useCallback(() => {
Haptics.selectionAsync().catch(() => {});
router.push(mentionPickerPath);
}, [mentionPickerPath]);
/** Auto-collapse to pill when input loses focus AND nothing's worth
* keeping the composer expanded for. Deferred one tick so a toolbar
* IconButton tap (which briefly resigns first responder) doesn't
* trigger a collapse before its onPress runs. */
const onBlur = useCallback(() => {
setTimeout(() => {
const empty =
text.trim().length === 0 &&
attachments.length === 0 &&
mentions.length === 0;
if (empty && !inputRef.current?.isFocused()) {
setExpanded(false);
onClearReplyTarget?.();
}
}, 80);
}, [text, attachments.length, mentions.length, onClearReplyTarget]);
const pillContent = (
<View
className="border-t border-border bg-background px-3 pt-2"
style={{ paddingBottom: (manageKeyboard ? insets.bottom : 0) + 8 }}
>
<Pressable
onPress={expand}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={pillLabel}
accessibilityState={{ disabled }}
className="flex-row items-center gap-2 h-11 px-4 rounded-full bg-secondary active:opacity-80"
>
<Ionicons
name={pillIcon}
size={18}
color={theme.mutedForeground}
/>
<Text className="text-base text-muted-foreground">
{disabled && disabledReason ? disabledReason : pillLabel}
</Text>
</Pressable>
</View>
);
const expandedContent = (
<View
className="bg-background px-3 pt-2 gap-2"
style={{ paddingBottom: (manageKeyboard ? insets.bottom : 0) + 4 }}
>
{replyTarget && (
<View className="px-3 py-1.5 rounded-md bg-secondary/60 gap-0.5">
<View className="flex-row items-center gap-2">
<Ionicons
name="return-up-back"
size={14}
color={theme.mutedForeground}
/>
<Text
className="flex-1 text-xs font-medium text-muted-foreground"
numberOfLines={1}
>
Replying to {replyTarget.actorName}
</Text>
<Pressable
onPress={onClearReplyTarget}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Cancel reply"
>
<Ionicons
name="close-circle"
size={16}
color={theme.mutedForeground}
/>
</Pressable>
</View>
{replyTarget.preview ? (
<Text
className="text-xs text-muted-foreground pl-5"
numberOfLines={2}
>
{stripMarkdown(replyTarget.preview)}
</Text>
) : null}
</View>
)}
<View
className="rounded-3xl border border-border bg-secondary"
style={{ borderCurve: "continuous" }}
>
{(mentions.length > 0 || attachments.length > 0) ? (
<View className="px-2 pt-2 pb-1">
<ComposerAttachmentRow
mentions={mentions}
attachments={attachments}
onRemoveMention={removeMention}
onRemoveAttachment={onRemoveAttachment}
onRetryAttachment={onRetryAttachment}
/>
</View>
) : null}
<TextInput
ref={inputRef}
value={text}
onChangeText={setText}
onBlur={onBlur}
placeholder={placeholder}
placeholderTextColor={theme.mutedForeground}
multiline
editable={!disabled}
className="px-4 pt-3 pb-1 text-base text-foreground"
style={{ minHeight: 28, maxHeight: 140, textAlignVertical: "top" }}
/>
<View className="flex-row items-center px-2 pb-2 pt-1">
{/* @ leads the toolbar — highest-signal attachment (only one
* that drives notifications) and cross-resource (people +
* issues), pride-of-place left. */}
<IconButton
name="at"
iconSize={20}
color={mentions.length > 0 ? theme.primary : undefined}
onPress={onAtPress}
accessibilityLabel="Mention someone or an issue"
className="h-8 w-8"
/>
<IconButton
name="image-outline"
iconSize={20}
onPress={onImagePress}
accessibilityLabel="Upload image"
className="h-8 w-8"
/>
<IconButton
name="attach-outline"
iconSize={20}
onPress={onFilePress}
accessibilityLabel="Upload file"
className="h-8 w-8"
/>
<View className="flex-1" />
{isSending && renderStop ? (
renderStop()
) : (
<IconButton
name="arrow-up"
iconSize={18}
color={theme.primaryForeground}
variant="default"
onPress={handleSubmit}
disabled={!canSend}
hitSlop={12}
className="h-8 w-8 rounded-full"
accessibilityLabel="Send"
accessibilityState={{ disabled: !canSend }}
/>
)}
</View>
</View>
</View>
);
const body = expanded ? expandedContent : pillContent;
// When the parent owns keyboard handling (chat.tsx wraps in
// KeyboardAvoidingView + SafeAreaView), skip the KeyboardStickyView —
// double-stacking causes the composer to jump twice on keyboard show.
if (!manageKeyboard) return body;
return (
<KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }}>
{body}
</KeyboardStickyView>
);
}

View File

@@ -0,0 +1,143 @@
/**
* Shared keyboard-bar toolbar for any markdown body input (issue
* description, comment, agent prompt). Linear-mobile range of buttons:
*
* @ · list · checkbox · code · quote · image · file
*
* All buttons map to **literal-character insertion** — no WYSIWYG. After
* a button fires, the user sees the raw markdown they just inserted; the
* read-only renderer (mobile hybrid markdown) shows the final visual.
*
* No bold / italic / heading: those are "style" tools that require live
* styled-text rendering inside `<TextInput>`, which RN can't do without
* swapping to `react-native-enriched`. See plan / research doc.
*
* Image / file props are optional so step 3 can ship the literal buttons
* before step 4 wires the picker + upload pipeline.
*/
import { Pressable, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
export interface MarkdownToolbarProps {
/** Toolbar `@` button → hook.handlers.onAtButtonPress. */
onAt: () => void;
/** Insert `- ` at the start of the current line. */
onList: () => void;
/** Insert `- [ ] ` at the start of the current line. */
onCheckbox: () => void;
/** Insert a fenced code block; caret lands in the empty middle line. */
onCode: () => void;
/** Insert `> ` at the start of the current line. */
onQuote: () => void;
/** Open image picker → upload → insert `![](url)`. Hidden when omitted. */
onImage?: () => void;
/** Open document picker → upload → insert `[📎 name](url)`. Hidden when omitted. */
onFile?: () => void;
/** Disable all buttons (during submit / upload-in-flight). */
disabled?: boolean;
}
const ICON_COLOR = "#71717a"; // muted-foreground
export function MarkdownToolbar({
onAt,
onList,
onCheckbox,
onCode,
onQuote,
onImage,
onFile,
disabled,
}: MarkdownToolbarProps) {
return (
<View className="flex-row items-center gap-1 px-2 py-1.5 border-t border-border bg-background">
<ToolbarButton
accessibilityLabel="Mention someone"
onPress={onAt}
disabled={disabled}
>
<Text className="text-base text-muted-foreground leading-none">@</Text>
</ToolbarButton>
<ToolbarButton
accessibilityLabel="Bullet list"
onPress={onList}
disabled={disabled}
>
<Ionicons name="list-outline" size={18} color={ICON_COLOR} />
</ToolbarButton>
<ToolbarButton
accessibilityLabel="Checklist"
onPress={onCheckbox}
disabled={disabled}
>
<Ionicons name="checkbox-outline" size={18} color={ICON_COLOR} />
</ToolbarButton>
<ToolbarButton
accessibilityLabel="Code block"
onPress={onCode}
disabled={disabled}
>
<Ionicons name="code-slash-outline" size={18} color={ICON_COLOR} />
</ToolbarButton>
<ToolbarButton
accessibilityLabel="Quote"
onPress={onQuote}
disabled={disabled}
>
{/* Ionicons has no good quote glyph — use the literal " character at
* a slightly larger size for visual parity with adjacent icons. */}
<Text className="text-xl text-muted-foreground leading-none -mt-1">
&quot;
</Text>
</ToolbarButton>
{onImage ? (
<ToolbarButton
accessibilityLabel="Attach image"
onPress={onImage}
disabled={disabled}
>
<Ionicons name="image-outline" size={18} color={ICON_COLOR} />
</ToolbarButton>
) : null}
{onFile ? (
<ToolbarButton
accessibilityLabel="Attach file"
onPress={onFile}
disabled={disabled}
>
<Ionicons name="attach-outline" size={18} color={ICON_COLOR} />
</ToolbarButton>
) : null}
</View>
);
}
function ToolbarButton({
onPress,
disabled,
accessibilityLabel,
children,
}: {
onPress: () => void;
disabled?: boolean;
accessibilityLabel: string;
children: React.ReactNode;
}) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
hitSlop={6}
className={cn(
"h-9 w-9 items-center justify-center rounded-md active:bg-secondary",
disabled && "opacity-40",
)}
>
{children}
</Pressable>
);
}

View File

@@ -0,0 +1,127 @@
/**
* Picker + upload glue for the markdown toolbar's image / file buttons.
*
* Each call:
* 1. Opens the appropriate picker (image library / document picker).
* 2. On user-cancel, resolves null (caller should treat as no-op — do not
* insert anything into the text).
* 3. Otherwise, streams the file to `/api/upload-file` via
* `api.uploadFile`, returning `{ url, filename }` for the caller to
* compose into the markdown insertion (`![](url)` or
* `[📎 name](url)`).
* 4. On any failure (size limit / network / 4xx / 5xx), shows an Alert
* and resolves null. Caller treats null as no-op so nothing partial
* ends up in the text.
*
* The hook tracks an `uploading` flag so callers can disable the toolbar
* during an in-flight upload (prevents double-pick + double-insert).
*/
import { useCallback, useState } from "react";
import { Alert } from "react-native";
import * as ImagePicker from "expo-image-picker";
import * as DocumentPicker from "expo-document-picker";
import { api, MAX_FILE_SIZE, type FileAsset } from "@/data/api";
export interface FileAttachResult {
/** Attachment id from the server. Callers MUST carry this to the mutation
* that creates / updates the comment, so the backend can re-parent the
* attachment from "issue-scoped" to "comment-scoped" (otherwise the
* attachment lives at the issue level forever and never cascades on
* comment delete). */
id: string;
url: string;
filename: string;
}
export interface UploadContext {
issueId?: string;
commentId?: string;
}
interface PickedAsset extends FileAsset {
size?: number;
}
export function useFileAttach() {
const [uploading, setUploading] = useState(false);
const upload = useCallback(
async (
asset: PickedAsset,
ctx?: UploadContext,
): Promise<FileAttachResult | null> => {
if (asset.size != null && asset.size > MAX_FILE_SIZE) {
Alert.alert(
"File too large",
"Files must be smaller than 100 MB.",
);
return null;
}
setUploading(true);
try {
const attachment = await api.uploadFile(asset, ctx);
return {
id: attachment.id,
url: attachment.url,
filename: attachment.filename,
};
} catch (err) {
Alert.alert(
"Upload failed",
err instanceof Error ? err.message : "Unknown error",
);
return null;
} finally {
setUploading(false);
}
},
[],
);
const pickAndUploadImage = useCallback(
async (ctx?: UploadContext): Promise<FileAttachResult | null> => {
const result = await ImagePicker.launchImageLibraryAsync({
// SDK 55: `MediaTypeOptions.Images` is supported (deprecation only
// hits SDK 56+). Stick with it until we upgrade.
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 1,
});
if (result.canceled) return null;
const picked = result.assets[0];
if (!picked) return null;
// expo-image-picker exposes `fileName` (camelCase) on iOS;
// fall back to a placeholder so the multipart Content-Disposition
// is never empty.
const asset: PickedAsset = {
uri: picked.uri,
name: picked.fileName ?? `image-${Date.now()}.jpg`,
type: picked.mimeType ?? "image/jpeg",
size: picked.fileSize,
};
return upload(asset, ctx);
},
[upload],
);
const pickAndUploadFile = useCallback(
async (ctx?: UploadContext): Promise<FileAttachResult | null> => {
const result = await DocumentPicker.getDocumentAsync({
type: "*/*",
copyToCacheDirectory: true,
});
if (result.canceled) return null;
const picked = result.assets[0];
if (!picked) return null;
const asset: PickedAsset = {
uri: picked.uri,
name: picked.name,
type: picked.mimeType ?? "application/octet-stream",
size: picked.size,
};
return upload(asset, ctx);
},
[upload],
);
return { pickAndUploadImage, pickAndUploadFile, uploading };
}

View File

@@ -0,0 +1,162 @@
/**
* Mobile InboxDetailLabel — type-aware second-line for inbox rows.
*
* Mirrors packages/views/inbox/components/inbox-detail-label.tsx exactly:
* for each InboxItemType the user sees the same label they would see on
* web/desktop. This is a Behavioral parity concern — if web shows "Set
* status to ✓ Done", mobile must show "Set status to ✓ Done" (rendered
* with mobile primitives, not the literal HTML).
*
* Web is i18n-driven (useT). Mobile v1 is English-only; when mobile ships
* i18n, mirror the namespace structure.
*/
import { View } from "react-native";
import type {
InboxItem,
InboxItemType,
IssueStatus,
IssuePriority,
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { useActorLookup } from "@/data/use-actor-name";
import { cn } from "@/lib/utils";
// Mirrors STATUS_CONFIG.label in packages/core/issues/config/status.ts
const STATUS_LABEL: Record<IssueStatus, string> = {
backlog: "Backlog",
todo: "Todo",
in_progress: "In Progress",
in_review: "In Review",
done: "Done",
blocked: "Blocked",
cancelled: "Cancelled",
};
// Mirrors PRIORITY_CONFIG.label in packages/core/issues/config/priority.ts
const PRIORITY_LABEL: Record<IssuePriority, string> = {
urgent: "Urgent",
high: "High",
medium: "Medium",
low: "Low",
none: "No priority",
};
// Mirrors useTypeLabels in packages/views/inbox/components/inbox-detail-label.tsx
const TYPE_LABEL: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
unassigned: "Unassigned",
assignee_changed: "Reassigned",
status_changed: "Status changed",
priority_changed: "Priority changed",
start_date_changed: "Start date changed",
due_date_changed: "Due date changed",
new_comment: "New comment",
mentioned: "Mentioned",
review_requested: "Review requested",
task_completed: "Task completed",
task_failed: "Task failed",
agent_blocked: "Agent blocked",
agent_completed: "Agent completed",
reaction_added: "Reaction added",
quick_create_done: "Quick-create done",
quick_create_failed: "Quick-create failed",
};
function shortDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function singleLine(value: string | null | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
export function InboxDetailLabel({
item,
className,
}: {
item: InboxItem;
className?: string;
}) {
const { getName } = useActorLookup();
const details = item.details ?? {};
// Cases with inline icons → Row layout.
if (item.type === "status_changed" && details.to) {
const status = details.to as IssueStatus;
return (
<View className={cn("flex-row items-center gap-1", className)}>
<Text className="text-xs text-muted-foreground">Set status to</Text>
<StatusIcon status={status} size={12} />
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{STATUS_LABEL[status] ?? status}
</Text>
</View>
);
}
if (item.type === "priority_changed" && details.to) {
const priority = details.to as IssuePriority;
return (
<View className={cn("flex-row items-center gap-1", className)}>
<Text className="text-xs text-muted-foreground">Set priority to</Text>
<PriorityIcon priority={priority} size={12} />
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{PRIORITY_LABEL[priority] ?? priority}
</Text>
</View>
);
}
// Single-string cases.
const text = (() => {
switch (item.type) {
case "issue_assigned":
case "assignee_changed":
if (details.new_assignee_id) {
const name = getName(
(details.new_assignee_type ?? "member") as "member" | "agent",
details.new_assignee_id,
);
return `Assigned to ${name}`;
}
return TYPE_LABEL[item.type];
case "unassigned":
return "Removed assignee";
case "due_date_changed":
return details.to
? `Set due date to ${shortDate(details.to)}`
: "Removed due date";
case "new_comment":
return singleLine(item.body) || TYPE_LABEL[item.type];
case "reaction_added":
return details.emoji
? `Reacted with ${details.emoji}`
: TYPE_LABEL[item.type];
case "quick_create_done":
return details.identifier
? `Created with agent: ${details.identifier}`
: TYPE_LABEL[item.type];
case "quick_create_failed": {
const detail = singleLine(details.error) || singleLine(item.body);
return detail ? `Failed: ${detail}` : TYPE_LABEL[item.type];
}
default:
return TYPE_LABEL[item.type] ?? item.type;
}
})();
return (
<Text
className={cn("text-xs text-muted-foreground", className)}
numberOfLines={1}
>
{text}
</Text>
);
}

View File

@@ -0,0 +1,91 @@
/**
* Inbox row content — the visual half, no gesture wrapping. Pulled out of
* (tabs)/inbox.tsx so swipeable-inbox-row.tsx can wrap it with the gesture
* recognizer without duplicating layout. Keep this file purely presentational
* — the swipe and the press behaviour live in the wrapper.
*
* Visual structure mirrors web's InboxListItem
* (packages/views/inbox/components/inbox-list-item.tsx). Per
* apps/mobile/CLAUDE.md "Visual alignment is baseline":
* - Right column stacks vertically: status icon on top row, time on bottom.
* - Secondary line uses the type-aware `InboxDetailLabel`, not raw body.
*/
import { Pressable, View } from "react-native";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { StatusIcon } from "@/components/ui/status-icon";
import { InboxDetailLabel } from "@/components/inbox/detail-label";
import { getInboxDisplayTitle } from "@/lib/inbox-display";
import { timeAgo } from "@/lib/time-ago";
import { cn } from "@/lib/utils";
interface Props {
item: InboxItem;
onPress: () => void;
}
export function InboxRow({ item, onPress }: Props) {
const isUnread = !item.read;
const displayTitle = getInboxDisplayTitle(item);
const actorType = item.actor_type ?? item.recipient_type;
const actorId = item.actor_id ?? item.recipient_id;
return (
<Pressable onPress={onPress} className="bg-background active:bg-secondary px-4 py-3">
<View className="flex-row gap-3">
<ActorAvatar type={actorType} id={actorId} size={36} showPresence />
<View className="flex-1 min-w-0">
{/* Top row: [unread dot + title] (left) | [status icon] (right) */}
<View className="flex-row items-center gap-2">
<View className="flex-row items-center gap-1.5 flex-1 min-w-0">
{isUnread ? (
<View className="size-1.5 rounded-full bg-brand shrink-0" />
) : null}
<Text
className={cn(
"flex-1 text-sm",
isUnread
? "font-medium text-foreground"
: "text-muted-foreground",
)}
numberOfLines={1}
>
{displayTitle}
</Text>
</View>
{item.issue_status ? (
<StatusIcon status={item.issue_status} size={14} />
) : null}
</View>
{/* Bottom row: [type-aware detail label] (left) | [time] (right).
Detail label mirrors web InboxDetailLabel — same per-type
wording (Mentioned / Set status to ... / Assigned to ... / etc),
not the raw markdown body. */}
<View className="flex-row items-center gap-2 mt-0.5">
<View className="flex-1 min-w-0">
<InboxDetailLabel
item={item}
className={
isUnread
? "text-muted-foreground"
: "text-muted-foreground/60"
}
/>
</View>
<Text
className={cn(
"text-xs shrink-0",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/60",
)}
>
{timeAgo(item.created_at)}
</Text>
</View>
</View>
</View>
</Pressable>
);
}

View File

@@ -0,0 +1,112 @@
/**
* Left-swipe-to-reveal-Archive wrapper for inbox rows.
*
* iOS pattern reference: Mail.app / Linear iOS / Things — a destructive
* red Archive action revealed by a leftward drag. **Reveal-only, no
* auto-fire**: the previous version archived on full swipe past threshold,
* which felt aggressive (no peek, easy to trigger by accident on a fast
* vertical scroll). Mail.app / Linear require an explicit tap on the
* revealed action; we now match that. A medium haptic fires once when the
* row crosses the action width during the drag so the gesture still feels
* confirmed.
*
* Why ReanimatedSwipeable (not the legacy Swipeable): RNGH 2.20+ ships the
* Reanimated-driven implementation that integrates cleanly with the
* existing reanimated@4 install and runs the swipe on the UI thread (the
* legacy version uses Animated, which janks on heavy lists). The
* gesture-handler root is already mounted in apps/mobile/app/_layout.tsx.
*
* Behaviour notes:
* - `friction=2` slightly slows the drag so the action doesn't open by
* accident on a fast vertical scroll that catches some horizontal motion.
* - `rightThreshold=80` is the open-detent — releasing past it keeps the
* Archive button revealed; releasing short of it snaps closed. No
* auto-archive on cross.
* - We `swipeable.close()` before invoking onArchive so the row's exit
* from the FlatList (driven by the optimistic mutation flipping
* `archived: true`, which the parent's `deduplicateInboxItems` filters
* out) doesn't race the spring close.
*/
import { useRef } from "react";
import { Pressable, View } from "react-native";
import Animated, {
type SharedValue,
useAnimatedReaction,
runOnJS,
} from "react-native-reanimated";
import ReanimatedSwipeable, {
type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { InboxRow } from "./inbox-row";
const ACTION_WIDTH = 80;
interface Props {
item: InboxItem;
onPress: () => void;
onArchive: () => void;
}
export function SwipeableInboxRow({ item, onPress, onArchive }: Props) {
const ref = useRef<SwipeableMethods>(null);
const fireArchive = () => {
// Close first so the swipe spring doesn't fight the row's removal from
// FlatList on the next render tick.
ref.current?.close();
onArchive();
};
return (
<ReanimatedSwipeable
ref={ref}
friction={2}
rightThreshold={ACTION_WIDTH}
renderRightActions={(_progress, drag) => (
<ArchiveAction onPress={fireArchive} drag={drag} />
)}
>
<InboxRow item={item} onPress={onPress} />
</ReanimatedSwipeable>
);
}
function ArchiveAction({
onPress,
drag,
}: {
onPress: () => void;
drag: SharedValue<number>;
}) {
// One-shot haptic when the drag crosses the action width threshold.
// useAnimatedReaction runs on the UI thread; runOnJS bridges to the
// Haptics.impactAsync call which has to live on JS.
useAnimatedReaction(
() => drag.value <= -ACTION_WIDTH,
(crossed, prev) => {
if (crossed && !prev) {
runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Medium);
}
},
[],
);
return (
<Animated.View style={{ width: ACTION_WIDTH }}>
<Pressable
onPress={onPress}
accessibilityLabel="Archive"
className="flex-1 items-center justify-center bg-destructive"
>
<View className="items-center gap-0.5">
<Ionicons name="archive-outline" size={20} color="white" />
<Text className="text-xs text-white">Archive</Text>
</View>
</Pressable>
</Animated.View>
);
}

View File

@@ -0,0 +1,153 @@
/**
* Activity (non-comment) timeline row. Mirrors the visual contract of web's
* `packages/views/issues/components/issue-detail.tsx:1046-1100`:
*
* 1. Single-line layout — `[lead icon] [actor name] [verb...truncate] [×N badge?] [time→]`
* 2. Contextual lead icon by action:
* - status_changed → StatusIcon for the NEW status (details.to)
* - priority_changed → PriorityIcon for the NEW priority (details.to)
* - due_date_changed → Calendar glyph
* - everything else → small ActorAvatar (size 16)
* The icon does the recognition work — user sees the new state at a
* glance before they read the verb.
* 3. Whole row is `text-xs text-muted-foreground` (web parity). Actor name
* is `font-medium` but inherits the muted color — activity is supposed
* to feel quiet next to comment bubbles.
* 4. Time is **relative**, right-aligned (`ml-auto`). Web shows absolute
* time in a hover tooltip; mobile has no hover so we just rely on
* relative for v1 (long-press → absolute time can be V2).
* 5. Coalesce ×N chip when `coalesced_count > 1`, except `task_completed` /
* `task_failed` which already bake the count into their phrase.
*/
import { View } from "react-native";
import Svg, { Line, Rect } from "react-native-svg";
import type {
IssuePriority,
IssueStatus,
TimelineEntry,
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { formatActivity } from "@/lib/format-activity";
import { timeAgo } from "@/lib/time-ago";
import { useActorLookup } from "@/data/use-actor-name";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
function CalendarGlyph({
size = 14,
stroke,
}: {
size?: number;
stroke: string;
}) {
return (
<Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
<Rect
x={2}
y={3.5}
width={12}
height={10.5}
rx={1.5}
stroke={stroke}
strokeWidth={1.2}
/>
<Line
x1={5}
y1={1.5}
x2={5}
y2={4.5}
stroke={stroke}
strokeWidth={1.2}
strokeLinecap="round"
/>
<Line
x1={11}
y1={1.5}
x2={11}
y2={4.5}
stroke={stroke}
strokeWidth={1.2}
strokeLinecap="round"
/>
<Line x1={2} y1={6.8} x2={14} y2={6.8} stroke={stroke} strokeWidth={1} />
</Svg>
);
}
function LeadIcon({
entry,
mutedFg,
}: {
entry: TimelineEntry;
mutedFg: string;
}) {
const details = (entry.details ?? {}) as Record<string, string>;
if (entry.action === "status_changed" && details.to) {
return <StatusIcon status={details.to as IssueStatus} size={14} />;
}
if (entry.action === "priority_changed" && details.to) {
return <PriorityIcon priority={details.to as IssuePriority} size={14} />;
}
if (
entry.action === "due_date_changed" ||
entry.action === "start_date_changed"
) {
return <CalendarGlyph size={14} stroke={mutedFg} />;
}
return (
<ActorAvatar
type={entry.actor_type as "member" | "agent"}
id={entry.actor_id}
size={16}
/>
);
}
export function ActivityRow({ entry }: { entry: TimelineEntry }) {
const { getName } = useActorLookup();
const { colorScheme } = useColorScheme();
const mutedFg = THEME[colorScheme].mutedForeground;
const resolveName = (
type: string | null | undefined,
id: string | null | undefined,
): string =>
getName(type as "member" | "agent" | null | undefined, id);
const actorName = resolveName(entry.actor_type, entry.actor_id);
const verb = formatActivity(entry, resolveName);
const showCoalesceBadge =
(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed";
return (
<View className="flex-row items-center px-4 gap-2">
<View className="w-4 items-center justify-center shrink-0">
<LeadIcon entry={entry} mutedFg={mutedFg} />
</View>
<Text
className="text-xs text-muted-foreground flex-1"
numberOfLines={1}
>
<Text className="text-xs text-muted-foreground font-medium">
{actorName}
</Text>
{verb ? (
<Text className="text-xs text-muted-foreground"> {verb}</Text>
) : null}
</Text>
{showCoalesceBadge ? (
<View className="bg-muted rounded px-1.5 py-0.5 shrink-0">
<Text className="text-xs font-medium text-muted-foreground tabular-nums">
×{entry.coalesced_count}
</Text>
</View>
) : null}
<Text className="text-xs text-muted-foreground shrink-0">
{timeAgo(entry.created_at)}
</Text>
</View>
);
}

View File

@@ -0,0 +1,106 @@
/**
* Double-state row that lives inside `IssueHeaderCard`. Pushes the
* `issue/[id]/runs` formSheet route — the Stack-header
* `<AgentHeaderBadge>` pushes the same route.
*
* ≥1 active task → [agent avatars] (pulse) Working
* 0 active, ≥1 past → 🕓 Runs · N
* never run → null (zero space)
*
* This row is the "discovery" surface (visible only when timeline isn't
* scrolled). The badge is the "ambient" surface (always visible during
* active tasks). One route, two entry points.
*/
import { useMemo } from "react";
import { Pressable, View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { AvatarStack, type StackActor } from "@/components/ui/avatar-stack";
import { PulseDot } from "@/components/ui/pulse-dot";
import {
issueActiveTasksOptions,
issueTasksOptions,
} from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
interface Props {
issueId: string;
}
export function AgentActivityRow({ issueId }: Props) {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { colorScheme } = useColorScheme();
const mutedFg = THEME[colorScheme].mutedForeground;
const { data: activeTasks = [] } = useQuery(
issueActiveTasksOptions(wsId, issueId),
);
const { data: allTasks = [] } = useQuery(issueTasksOptions(wsId, issueId));
const activeCount = activeTasks.length;
// "Past" = tasks not currently active. The /task-runs endpoint returns the
// full list, so we filter rather than fetching a separate past-only query.
const pastCount = useMemo(
() =>
allTasks.filter(
(t) =>
t.status === "completed" ||
t.status === "failed" ||
t.status === "cancelled",
).length,
[allTasks],
);
if (activeCount === 0 && pastCount === 0) {
return null;
}
return (
<Pressable
onPress={() => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/issue/[id]/runs",
params: { workspace: wsSlug, id: issueId },
});
}}
className="flex-row items-center gap-2 -mx-2 px-2 py-2 rounded-lg active:bg-secondary"
>
{activeCount > 0 ? (
<ActiveContent
actors={activeTasks.map<StackActor>((t) => ({
type: "agent",
id: t.agent_id,
}))}
/>
) : (
<IdleContent count={pastCount} mutedFg={mutedFg} />
)}
<Ionicons name="chevron-forward" size={16} color={mutedFg} />
</Pressable>
);
}
function ActiveContent({ actors }: { actors: StackActor[] }) {
return (
<View className="flex-1 flex-row items-center gap-2">
<AvatarStack actors={actors} max={3} size={24} />
<PulseDot />
<Text className="text-sm font-medium text-foreground">Working</Text>
</View>
);
}
function IdleContent({ count, mutedFg }: { count: number; mutedFg: string }) {
return (
<View className="flex-1 flex-row items-center gap-2">
<Ionicons name="time-outline" size={16} color={mutedFg} />
<Text className="text-sm text-foreground">Runs · {count}</Text>
</View>
);
}

View File

@@ -0,0 +1,60 @@
/**
* Ambient status badge for the issue detail Stack header (right side).
* Renders only when ≥1 agent task is active on this issue; otherwise null.
*
* Why this exists: the in-card `<AgentActivityRow>` is the first-time-
* discovery surface (full "Working" text + larger avatars), but it scrolls
* away with the timeline. Agent tasks run for minutes to tens of minutes;
* users actively scroll during that window to read past comments. The
* "is anything still working" signal needs a consistent location — see
* Apple HIG "Progress Indicators" + the agent-UX "ambient status badge"
* pattern (https://www.aiuxdesign.guide/patterns/agent-status-monitoring).
*
* Tap pushes the `issue/[id]/runs` formSheet route — the in-card
* AgentActivityRow does the same. One route, two entry points, no
* duplicate sheet state.
*/
import { Pressable } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { AvatarStack, type StackActor } from "@/components/ui/avatar-stack";
import { PulseDot } from "@/components/ui/pulse-dot";
import { issueActiveTasksOptions } from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
interface Props {
issueId: string;
}
export function AgentHeaderBadge({ issueId }: Props) {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { data: active = [] } = useQuery(
issueActiveTasksOptions(wsId, issueId),
);
if (active.length === 0) return null;
const actors = active.map<StackActor>((t) => ({
type: "agent",
id: t.agent_id,
}));
return (
<Pressable
onPress={() => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/issue/[id]/runs",
params: { workspace: wsSlug, id: issueId },
});
}}
hitSlop={8}
accessibilityLabel="Agent working — open runs"
className="flex-row items-center gap-1.5 px-2 py-1 active:opacity-60"
>
<AvatarStack actors={actors} max={2} size={20} />
<PulseDot size={6} />
</Pressable>
);
}

View File

@@ -0,0 +1,66 @@
/**
* Generic attribute chip used in the issue-detail header. Each chip pairs
* an icon node (any RN element — StatusIcon, PriorityIcon, ActorAvatar,
* emoji, etc) with a textual label. Filled = the property has a value;
* dimmed = empty placeholder ("Label", "Cycle", ...).
*
* The chip becomes a Pressable when `onPress` is provided. Without onPress
* it renders as a plain View — used for read-only chips (e.g. project
* chip while picker is deferred).
*/
import { Pressable, View } from "react-native";
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
interface Props {
icon: ReactNode;
label: string;
variant?: "filled" | "dimmed";
onPress?: () => void;
className?: string;
}
export function AttributeChip({
icon,
label,
variant = "filled",
onPress,
className,
}: Props) {
const containerClass = cn(
"flex-row items-center gap-1.5 rounded-full border px-2.5 py-1",
variant === "filled"
? "border-border bg-secondary/60"
: "border-dashed border-muted-foreground/30 bg-transparent",
className,
);
const labelClass = cn(
"text-xs",
variant === "filled"
? "text-foreground"
: "text-muted-foreground/70",
);
const inner = (
<>
{icon}
<Text className={labelClass} numberOfLines={1}>
{label}
</Text>
</>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
className={cn(containerClass, "active:bg-secondary")}
hitSlop={4}
>
{inner}
</Pressable>
);
}
return <View className={containerClass}>{inner}</View>;
}

View File

@@ -0,0 +1,209 @@
/**
* Issue-detail attribute chip row. Linear iOS-inspired layout: each
* editable attribute renders as a tappable chip; tapping pushes a
* formSheet picker route. The route reads the issue from the TanStack
* Query detail cache and fires its own mutation — no onChange callback
* round-trip back to AttributeRow.
*
* Picker route map (every entry is registered in `_layout.tsx` with
* shared SHEET_OPTIONS — formSheet + iOS native grabber + explicit
* numeric detents):
* status → issue/[id]/picker/status
* priority → issue/[id]/picker/priority
* assignee → issue/[id]/picker/assignee
* labels → issue/[id]/picker/label (multi-select, stays open)
* project → issue/[id]/picker/project
* due_date → issue/[id]/picker/due-date
*/
import { useMemo } from "react";
import { View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type {
Issue,
IssuePriority,
} from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { ProjectIcon } from "@/components/ui/project-icon";
import { AttributeChip } from "./attribute-chip";
import { useActorLookup } from "@/data/use-actor-name";
import { findProject, projectListOptions } from "@/data/queries/projects";
import { useWorkspaceStore } from "@/data/workspace-store";
import {
STATUS_LABEL,
PRIORITY_LABEL as PRIORITY_FULL_LABEL,
} from "@/lib/issue-status";
// Chip placeholder shortens `none` from "No priority" → "Priority" so the
// unset chip reads as a placeholder, not as a confusing assigned value.
const PRIORITY_CHIP_LABEL: Record<IssuePriority, string> = {
...PRIORITY_FULL_LABEL,
none: "Priority",
};
/**
* The picker fields the issue-detail attribute row can open. Bound to a
* map of typed Expo Router pathnames so typos become compile errors
* (previously the call site used `as never` on a template string, which
* silently accepted anything).
*/
type IssuePickerField =
| "status"
| "priority"
| "assignee"
| "label"
| "project"
| "due-date";
const ISSUE_PICKER_PATHNAMES = {
status: "/[workspace]/issue/[id]/picker/status",
priority: "/[workspace]/issue/[id]/picker/priority",
assignee: "/[workspace]/issue/[id]/picker/assignee",
label: "/[workspace]/issue/[id]/picker/label",
project: "/[workspace]/issue/[id]/picker/project",
"due-date": "/[workspace]/issue/[id]/picker/due-date",
} as const satisfies Record<IssuePickerField, string>;
function formatDueDate(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function AttributeRow({ issue }: { issue: Issue }) {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { getName } = useActorLookup();
// Project read-only — fetch list to look up the title + icon. Cheap
// (cached after first issue-detail visit).
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const project = useMemo(
() => findProject(projects, issue.project_id),
[projects, issue.project_id],
);
const labels = issue.labels ?? [];
const assigneeValue =
issue.assignee_type && issue.assignee_id
? { type: issue.assignee_type, id: issue.assignee_id }
: null;
const assigneeName = assigneeValue
? getName(assigneeValue.type, assigneeValue.id)
: null;
const dueLabel = formatDueDate(issue.due_date);
const openPicker = (field: IssuePickerField) => {
if (!wsSlug) return;
router.push({
pathname: ISSUE_PICKER_PATHNAMES[field],
params: { workspace: wsSlug, id: issue.id },
});
};
return (
<View className="flex-row flex-wrap gap-2">
{/* Status — always shown */}
<AttributeChip
icon={<StatusIcon status={issue.status} size={14} />}
label={STATUS_LABEL[issue.status]}
variant="filled"
onPress={() => openPicker("status")}
/>
{/* Priority */}
<AttributeChip
icon={<PriorityIcon priority={issue.priority} size={14} />}
label={PRIORITY_CHIP_LABEL[issue.priority]}
variant={issue.priority === "none" ? "dimmed" : "filled"}
onPress={() => openPicker("priority")}
/>
{/* Assignee */}
{assigneeValue ? (
<AttributeChip
icon={
<ActorAvatar
type={assigneeValue.type}
id={assigneeValue.id}
size={16}
showPresence
/>
}
label={assigneeName ?? "Unknown"}
variant="filled"
onPress={() => openPicker("assignee")}
/>
) : (
<AttributeChip
icon={
<View className="size-4 rounded-full border border-dashed border-muted-foreground/40" />
}
label="Assignee"
variant="dimmed"
onPress={() => openPicker("assignee")}
/>
)}
{/* Each existing label renders as its own chip. Tap opens the
label picker (multi-select toggle). No quick-detach gesture
on the chip itself in v1 — Linear iOS uses long-press for
that, deferred until requested. */}
{labels.map((label) => (
<AttributeChip
key={label.id}
icon={
<View
className="size-2.5 rounded-full"
style={{ backgroundColor: label.color }}
/>
}
label={label.name}
variant="filled"
onPress={() => openPicker("label")}
/>
))}
{labels.length === 0 ? (
<AttributeChip
icon={<Text className="text-xs text-muted-foreground/70"></Text>}
label="Label"
variant="dimmed"
onPress={() => openPicker("label")}
/>
) : null}
{/* Project */}
{project ? (
<AttributeChip
icon={<ProjectIcon icon={project.icon} size="sm" />}
label={project.title}
variant="filled"
onPress={() => openPicker("project")}
/>
) : (
<AttributeChip
icon={
<View className="size-3.5 rounded-sm border border-dashed border-muted-foreground/40" />
}
label="Project"
variant="dimmed"
onPress={() => openPicker("project")}
/>
)}
{/* Due date */}
<AttributeChip
icon={<Text className="text-xs text-muted-foreground/80">📅</Text>}
label={dueLabel ?? "Due date"}
variant={dueLabel ? "filled" : "dimmed"}
onPress={() => openPicker("due-date")}
/>
</View>
);
}

View File

@@ -0,0 +1,160 @@
/**
* Standalone attachment list for comment cards.
*
* Mirrors the design of web's `AttachmentList` in
* `packages/views/issues/components/comment-card.tsx:121-159` — renders
* any attachment whose URL the markdown content didn't already reference,
* with same-file dedup so a duplicate upload referenced inline doesn't
* also appear below.
*
* The data-contract parity goal: a comment authored on mobile (which has
* no inline-insert path — see `inline-comment-composer.tsx`) carries its
* attachments via the `attachments` field only, with no `![](url)` in
* `content`. Web reads it back and `AttachmentList` puts the attachments
* below the body. Mobile reads it back here and does the same. A comment
* authored on web with inline images already inside the markdown renders
* inline on both clients via `MarkdownImage`, and this list returns null
* because there's nothing "leftover" to show.
*
* For v1 we render images via the same `MarkdownImage` used by inline
* markdown rendering (consistent aspect-ratio + lightbox behavior). Non-
* image attachments render as a tappable file card showing 📎 + filename
* + size hint, opening the canonical download URL on tap.
*/
import { useMemo } from "react";
import { Linking, Pressable, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { Attachment } from "@multica/core/types";
import { MarkdownImage } from "@/lib/markdown/markdown-image";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { Text } from "@/components/ui/text";
interface Props {
attachments?: Attachment[];
/** The comment's markdown content. Attachments referenced inside it via
* `![](url)` or `[name](url)` are skipped so they aren't double-rendered.
* Pass `undefined` (not just an empty string) when the comment has no
* body — that disables the inline-reference filter and renders all
* supplied attachments. */
content?: string;
}
export function CommentAttachmentList({ attachments, content }: Props) {
const { colorScheme } = useColorScheme();
const theme = THEME[colorScheme];
const standalone = useMemo(() => {
if (!attachments || attachments.length === 0) return [];
if (!content) return attachments;
return attachments.filter((a) => {
// Skip attachments whose URL is already referenced inline in the
// markdown — they'll render via MarkdownImage (images) or a markdown
// link (files), and we'd otherwise show them twice.
if (content.includes(a.url)) return false;
// Dedup: if another attachment with the same file identity (name,
// type, size) is already inline in the content, this one is a
// duplicate upload — skip it. Mirrors web's
// `comment-card.tsx:132-140` defense.
const hasSiblingInContent = attachments.some(
(other) =>
other.id !== a.id &&
other.filename === a.filename &&
other.content_type === a.content_type &&
other.size_bytes === a.size_bytes &&
content.includes(other.url),
);
if (hasSiblingInContent) return false;
return true;
});
}, [attachments, content]);
if (standalone.length === 0) return null;
return (
<View className="gap-1.5">
{standalone.map((attachment) => {
const isImage = attachment.content_type.startsWith("image/");
if (isImage) {
return (
<MarkdownImage
key={attachment.id}
uri={attachment.url}
alt={attachment.filename}
attachments={attachments}
/>
);
}
return (
<FileCard
key={attachment.id}
attachment={attachment}
theme={theme}
/>
);
})}
</View>
);
}
function FileCard({
attachment,
theme,
}: {
attachment: Attachment;
theme: typeof THEME["light"];
}) {
const sizeLabel = formatBytes(attachment.size_bytes);
return (
<Pressable
onPress={() => {
// download_url is the signed HTTPS link; opening it hands off to
// Safari which handles auth-token-free download + previewing for
// common types (PDF, txt). Mirrors what the markdown link renderer
// does for `[name](url)`.
if (attachment.download_url) {
void Linking.openURL(attachment.download_url);
}
}}
accessibilityRole="button"
accessibilityLabel={`Open ${attachment.filename}`}
className="flex-row items-center gap-2 px-3 py-2 rounded-md bg-secondary/60 active:opacity-80"
>
<Ionicons
name="document-outline"
size={20}
color={theme.mutedForeground}
/>
<View className="flex-1">
<Text
className="text-sm text-foreground"
numberOfLines={1}
>
{attachment.filename}
</Text>
{sizeLabel ? (
<Text className="text-xs text-muted-foreground">{sizeLabel}</Text>
) : null}
</View>
<Ionicons
name="download-outline"
size={18}
color={theme.mutedForeground}
/>
</Pressable>
);
}
function formatBytes(bytes: number): string | null {
if (!bytes || bytes <= 0) return null;
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
const formatted =
value < 10 ? value.toFixed(1) : Math.round(value).toString();
return `${formatted} ${units[unitIndex]}`;
}

View File

@@ -0,0 +1,578 @@
/**
* Comment timeline row. Rounded gray bubble containing the parent comment
* plus, when applicable, every descendant reply stacked inline. The bubble
* boundary itself is the thread indicator — no "↪ Replying to" header, no
* recursive indentation. This matches the user's design call: "放在一个 card
* 内部就行了 / no need for the Replying to label".
*
* Mobile flat-list rule (apps/mobile/CLAUDE.md): same comments as web,
* different layout — web shows recursive tree, mobile shows one bubble per
* thread. Counts agree (no comment is dropped or duplicated).
*
* Interaction: long-press inside a bubble fires a native iOS
* `ActionSheetIOS` with the comment's actions (Reply, React…, Copy,
* Select Text, Copy Link, Resolve, Delete). While the sheet is on screen
* the targeted bubble's border highlights. See `useCommentLongPress` in
* `./comment-context-menu.tsx`.
*
* Resolved threads render in a collapsed `<ResolvedThreadBar>` by default —
* mirrors the same state language web uses (`packages/views/issues/
* components/resolved-thread-bar.tsx`), but the visual is a single-line
* tap-to-expand bar at iOS section-row scale. Tap expands the bar in place;
* when expanded the resolved indicator stays at the top of the body so the
* user keeps the "this thread is resolved" signal even while reading.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withTiming,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import type { Reaction, TimelineEntry } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { useActorLookup } from "@/data/use-actor-name";
import { timeAgo } from "@/lib/time-ago";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Markdown } from "@/lib/markdown";
import { CommentAttachmentList } from "@/components/issue/comment-attachment-list";
import {
discardFailedComment,
useCreateComment,
useToggleCommentReaction,
} from "@/data/mutations/issues";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { issueAttachmentsOptions } from "@/data/queries/issues";
import { useFailedCommentsStore } from "@/data/stores/failed-comments-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { cn } from "@/lib/utils";
import { ReactionBar } from "./reaction-bar";
import { useCommentLongPress } from "./comment-context-menu";
import { useCommentSelectStore } from "@/data/comment-select-store";
interface Props {
entry: TimelineEntry;
/** Flattened descendant replies. Rendered inline below the parent inside
* the same bubble, separated by a hairline divider. */
replies?: TimelineEntry[];
/** Plumbed through so each CommentBody can wire its reaction toggle to
* the correct issue's mutation key. */
issueId: string;
/** Human-readable identifier (e.g. `MUL-123`) used to build the shareable
* web URL for the long-press "Copy Link" item. Optional — that item
* hides when missing. */
issueIdentifier: string | undefined;
/** Inbox deep-link flash target. When this matches the root entry id we
* flash the outer bubble (ring + bg). When it matches a reply id we
* flash that reply's wrapper (bg only). Mirrors web's distinction at
* packages/views/issues/components/comment-card.tsx:498-682. */
highlightedCommentId?: string | null;
}
export function CommentCard({
entry,
replies = [],
issueId,
issueIdentifier,
highlightedCommentId,
}: Props) {
// Resolved threads default to a single-line bar; tap expands in place for
// the current session. Unmount (scroll out of viewport) resets — same
// behavior as iOS Mail's "tap to expand a thread" pattern. Replies cannot
// themselves be resolved (server enforces root-only), so the resolved flag
// on the root is the single source of truth for this card.
const resolved = !!entry.resolved_at;
const [expanded, setExpanded] = useState(false);
// Highlight ring while a long-press action sheet is on screen — child
// CommentBody flips this via onPressChange so the outer bubble shell can
// visually bind the sheet to the targeted entry.
const [pressedEntryId, setPressedEntryId] = useState<string | null>(null);
const handlePressChange = useCallback(
(entryId: string, pressed: boolean) => {
setPressedEntryId((cur) => {
if (pressed) return entryId;
return cur === entryId ? null : cur;
});
},
[],
);
const isHighlighted =
pressedEntryId === entry.id ||
replies.some((r) => r.id === pressedEntryId);
// Translucent primary-tinted background while ANY body inside this card
// is in text-selection mode. Subtle visual cue that replaces the prior
// Done pill — exit is via scroll / tab switch / selecting another body.
const selectingId = useCommentSelectStore((s) => s.selectingId);
const isSelectingHere =
selectingId === entry.id || replies.some((r) => r.id === selectingId);
// Inbox deep-link target inside a resolved thread expands automatically —
// otherwise tapping a notification would just reveal a bar with no content
// and force the user to tap again.
useEffect(() => {
if (!resolved || !highlightedCommentId) return;
if (
highlightedCommentId === entry.id ||
replies.some((r) => r.id === highlightedCommentId)
) {
setExpanded(true);
}
}, [resolved, highlightedCommentId, entry.id, replies]);
if (resolved && !expanded) {
return (
<ResolvedThreadBar
entry={entry}
replies={replies}
onExpand={() => setExpanded(true)}
/>
);
}
return (
<View className="px-4">
<View className="rounded-2xl">
{/* Bubble uses `surface-1` (L 98%) — extremely subtle elevation
* above the page, visible mostly through the rounded edge rather
* than the fill (iOS settings cell feel; see Refactoring UI #4
* "cards subtle from page"). Internal markdown elements (table
* headers / code blocks via markdown-style.ts) use `surface-2`
* (L 90%), 8% darker than the bubble — well over the 5%
* perceptibility threshold so the inner box is clearly framed.
* Border (L 84%) adds 6% on top for the outline. See global.css
* for the full 5-tier elevation scale.
*
* Resolved-and-expanded path dims the bubble to 70% so the
* "this is settled" signal persists even while reading the
* body — mirrors web's muted resolved card visual. */}
<View
className={cn(
"bg-surface-1 rounded-2xl px-4 py-3 gap-3 border-2 border-transparent transition-colors",
resolved && "opacity-70",
isHighlighted && "border-primary/30",
isSelectingHere && "bg-primary/5 border-primary/30",
)}
>
{resolved ? (
<ResolvedIndicator
entry={entry}
onCollapse={() => setExpanded(false)}
/>
) : null}
<CommentBody
entry={entry}
issueId={issueId}
issueIdentifier={issueIdentifier}
onPressChange={handlePressChange}
/>
{replies.map((reply) => (
<View key={reply.id} className="border-t border-border/60 pt-3">
<CommentBody
entry={reply}
issueId={issueId}
issueIdentifier={issueIdentifier}
onPressChange={handlePressChange}
/>
<ReplyHighlightOverlay
active={highlightedCommentId === reply.id}
/>
</View>
))}
</View>
<RootHighlightOverlay active={highlightedCommentId === entry.id} />
</View>
</View>
);
}
/**
* Compact "thread is resolved" bar — substitutes the full card when a
* resolved root is collapsed (default state). Tap anywhere to expand.
*
* Mirrors web's `<ResolvedThreadBar>` (`packages/views/issues/components/
* resolved-thread-bar.tsx`): checkmark + N participant authors + reply
* count + chevron. On mobile we drop the dedicated <Card> chrome and use
* the same `bg-surface-1` bubble so the resolved bar reads as the same
* "row" rhythm as the full card it stands in for.
*/
function ResolvedThreadBar({
entry,
replies,
onExpand,
}: {
entry: TimelineEntry;
replies: TimelineEntry[];
onExpand: () => void;
}) {
const { getName } = useActorLookup();
const { colorScheme } = useColorScheme();
const mutedFg = THEME[colorScheme].mutedForeground;
// Unique participant set across root + replies, preserving chronological
// order of first appearance. Up to two authors are named; the rest are
// rolled into "+N more" so the bar stays a single line on a narrow phone.
const authorsLabel = useMemo(() => {
const MAX_NAMED = 2;
const seen = new Set<string>();
const ordered: { type: string | null; id: string | null }[] = [];
for (const e of [entry, ...replies]) {
const key = `${e.actor_type}:${e.actor_id}`;
if (seen.has(key)) continue;
seen.add(key);
ordered.push({ type: e.actor_type, id: e.actor_id });
}
const named = ordered
.slice(0, MAX_NAMED)
.map((a) =>
getName(a.type as "member" | "agent" | null | undefined, a.id),
)
.join(", ");
const remaining = ordered.length - MAX_NAMED;
return remaining > 0 ? `${named} +${remaining}` : named;
}, [entry, replies, getName]);
const total = 1 + replies.length;
return (
<View className="px-4">
<Pressable
onPress={onExpand}
className="flex-row items-center gap-2.5 px-4 py-3 rounded-2xl bg-surface-1 active:opacity-70"
accessibilityRole="button"
accessibilityLabel={`Resolved thread by ${authorsLabel}, ${total} ${total === 1 ? "message" : "messages"}. Tap to expand.`}
>
<Ionicons name="checkmark-circle" size={18} color={mutedFg} />
<Text
className="flex-1 text-sm text-muted-foreground"
numberOfLines={1}
>
Resolved · {total} {total === 1 ? "message" : "messages"} by{" "}
{authorsLabel}
</Text>
<Ionicons name="chevron-down" size={14} color={mutedFg} />
</Pressable>
</View>
);
}
/**
* Resolved indicator row that sits at the top of an expanded resolved
* thread. Carries the "who resolved + when" attribution and a collapse
* affordance — equivalent to web's "Mark as resolved" header bar
* (`packages/views/issues/components/comment-card.tsx:519-532`).
*
* Tap collapses the thread back to the bar without firing the
* <CommentBody> long-press action sheet (the row is a self-contained
* Pressable, sits above CommentBody in the bubble's gap-3 layout).
*/
function ResolvedIndicator({
entry,
onCollapse,
}: {
entry: TimelineEntry;
onCollapse: () => void;
}) {
const { getName } = useActorLookup();
const { colorScheme } = useColorScheme();
const mutedFg = THEME[colorScheme].mutedForeground;
const resolverName = getName(
entry.resolved_by_type as "member" | "agent" | null | undefined,
entry.resolved_by_id,
);
return (
<Pressable
onPress={onCollapse}
className="flex-row items-center gap-2 active:opacity-60"
accessibilityRole="button"
accessibilityLabel="Collapse resolved thread"
>
<Ionicons name="checkmark-circle" size={14} color={mutedFg} />
<Text className="text-xs text-muted-foreground flex-1" numberOfLines={1}>
Resolved by{" "}
<Text className="text-xs text-foreground font-medium">
{resolverName}
</Text>
{entry.resolved_at ? ` · ${timeAgo(entry.resolved_at)}` : ""}
</Text>
<Text className="text-xs text-muted-foreground">Collapse</Text>
</Pressable>
);
}
/**
* Animated highlight overlay for a root comment bubble. Sits absolute-
* positioned over the parent <View className="rounded-2xl">, no pointer
* capture (long-press still works through it). Border + background wash
* — equivalent to web's `ring-2 ring-brand/50 bg-brand/5`.
*
* Reflow note: animating `borderWidth` would push children every frame,
* so we keep it constant at 2 and animate `opacity` 0→1→0. Same trick
* for the wash. Single shared value, one animated style.
*/
function RootHighlightOverlay({ active }: { active: boolean }) {
const progress = useSharedValue(0);
useEffect(() => {
if (!active) return;
// 700ms fade-in → 1800ms hold → 700ms fade-out. Matches web's
// `transition-colors duration-700` + `setTimeout(2500)` timing.
progress.value = withSequence(
withTiming(1, { duration: 700 }),
withDelay(1800, withTiming(0, { duration: 700 })),
);
}, [active, progress]);
const style = useAnimatedStyle(() => ({ opacity: progress.value }));
// Brand colour comes from the `brand` token; alpha via NativeWind `/50`
// syntax mirrors web's `ring-brand/50 bg-brand/5`. Only opacity is
// animated — the borderColor / backgroundColor stay constant, so
// className is safe here (animating those channels via className isn't).
return (
<Animated.View
pointerEvents="none"
className="absolute inset-0 rounded-2xl border-2 border-brand/50 bg-brand/5"
style={style}
/>
);
}
/**
* Animated wash overlay for a reply row. Same timing as root, but no
* border — mirrors web's reply branch which applies only `bg-brand/5`
* (packages/views/issues/components/comment-card.tsx:682).
*/
function ReplyHighlightOverlay({ active }: { active: boolean }) {
const progress = useSharedValue(0);
useEffect(() => {
if (!active) return;
progress.value = withSequence(
withTiming(1, { duration: 700 }),
withDelay(1800, withTiming(0, { duration: 700 })),
);
}, [active, progress]);
const style = useAnimatedStyle(() => ({ opacity: progress.value }));
return (
<Animated.View
pointerEvents="none"
className="absolute inset-0 bg-brand/5"
style={style}
/>
);
}
function CommentBody({
entry,
issueId,
issueIdentifier,
onPressChange,
}: {
entry: TimelineEntry;
issueId: string;
issueIdentifier: string | undefined;
onPressChange?: (entryId: string, pressed: boolean) => void;
}) {
// When this comment is the active selection target, drop the long-press
// wrapper AND make the markdown selectable — so the next long-press
// routes to UIKit's native text-selection magnifier instead of our
// gesture handler. Selection mode is exited via the Done pill, scrolling
// the timeline, or unmounting the issue screen.
const isSelecting = useCommentSelectStore(
(s) => s.selectingId === entry.id,
);
const { getName } = useActorLookup();
const userId = useAuthStore((s) => s.user?.id);
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const toggle = useToggleCommentReaction(issueId);
const qc = useQueryClient();
const createComment = useCreateComment(issueId);
// Failed-comment state for THIS entry — undefined when the entry is a
// normal server-backed comment OR an in-flight optimistic. Only set when
// the matching `useCreateComment` mutation errored and the entry was
// intentionally left in the cache to surface inline retry.
const failed = useFailedCommentsStore((s) => s.failed[entry.id]);
// Same query as IssueDescription — TanStack dedupes so this fires once
// per issue regardless of how many comments need to resolve attachments.
const { data: attachments } = useQuery(
issueAttachmentsOptions(wsId, issueId),
);
const name = getName(
entry.actor_type as "member" | "agent" | null | undefined,
entry.actor_id,
);
const edited =
entry.updated_at &&
entry.created_at &&
entry.updated_at !== entry.created_at;
// Reactions live on TimelineEntry.reactions (mirrored from Comment).
// Pass through to the bar; toggle finds existing match by emoji + actor.
const reactions: Reaction[] = (entry.reactions ?? []) as Reaction[];
const onToggleReaction = useCallback(
(emoji: string) => {
const existing = reactions.find(
(r) =>
r.emoji === emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
toggle.mutate({ commentId: entry.id, emoji, existing });
},
[reactions, userId, toggle, entry.id],
);
const handleRetry = useCallback(() => {
if (!failed || !wsId) return;
// Remove the stale optimistic + failed marker BEFORE re-firing so the
// mutation's own optimistic insert lands on a clean slate instead of
// creating a duplicate row. The new attempt mints a fresh optimistic id.
discardFailedComment(qc, wsId, issueId, entry.id);
createComment.mutate({
content: failed.content,
parentId: failed.parentId,
attachmentIds: failed.attachmentIds,
});
}, [failed, qc, wsId, issueId, entry.id, createComment]);
const handleDiscard = useCallback(() => {
if (!wsId) return;
discardFailedComment(qc, wsId, issueId, entry.id);
}, [qc, wsId, issueId, entry.id]);
// Per-comment attachments render in two complementary places:
// - inline via the markdown renderer when the content references
// them with `![](url)` (typical for web/desktop comments authored
// in the rich editor)
// - via <CommentAttachmentList> below the body when they exist but
// aren't referenced in markdown (mobile-authored comments take this
// path — see inline-comment-composer.tsx for why mobile doesn't
// inline-insert).
// Mirrors web's split: comment-card.tsx:124 `AttachmentList`.
//
// When NOT selecting: long-press fires the native ActionSheetIOS via
// useCommentLongPress. Markdown is non-selectable so the long-press
// gesture doesn't race UIKit's text selection.
//
// When selecting: long-press wrapper is gone, markdown is selectable.
// The next long-press fires UIKit's native text-selection magnifier
// + handles + Copy/Look Up callout. The outer bubble shell carries a
// translucent primary-tint background as the mode cue (no Done pill).
// Exit: scroll the timeline, leave the issue, or long-press another body.
const longPress = useCommentLongPress(entry, issueId, issueIdentifier);
useEffect(() => {
if (isSelecting) return;
onPressChange?.(entry.id, longPress.isPressed);
}, [longPress.isPressed, entry.id, isSelecting, onPressChange]);
const body = (
<View className="gap-2">
<View className="flex-row items-center gap-2">
<ActorAvatar
type={entry.actor_type as "member" | "agent"}
id={entry.actor_id}
size={24}
showPresence
/>
<Text className="text-sm font-medium text-foreground">{name}</Text>
<Text className="text-xs text-muted-foreground">
· {timeAgo(entry.created_at)}
{edited ? " · (edited)" : ""}
</Text>
</View>
{entry.content ? (
<Markdown
content={entry.content}
attachments={attachments}
selectable={isSelecting}
/>
) : null}
<CommentAttachmentList
attachments={entry.attachments}
content={entry.content}
/>
{failed ? (
<FailedActions
error={failed.error}
onRetry={handleRetry}
onDiscard={handleDiscard}
/>
) : (
<ReactionBar
reactions={reactions}
currentUserId={userId}
onToggle={onToggleReaction}
/>
)}
</View>
);
if (isSelecting) return body;
return (
<Pressable onLongPress={longPress.onLongPress} delayLongPress={500}>
{body}
</Pressable>
);
}
/**
* Inline retry strip shown beneath a failed optimistic comment body. Sits
* where ReactionBar normally lives — same vertical rhythm, but the slot
* carries the error message + Retry/Discard buttons. Single source of the
* error surface (no parallel toast), so the user always lands on the row
* they typed if they come back later.
*/
function FailedActions({
error,
onRetry,
onDiscard,
}: {
error: string;
onRetry: () => void;
onDiscard: () => void;
}) {
const { colorScheme } = useColorScheme();
const destructive = THEME[colorScheme].destructive;
return (
<View className="flex-row items-center gap-2 mt-0.5">
<Ionicons name="alert-circle" size={14} color={destructive} />
<Text
className="flex-1 text-xs text-destructive"
numberOfLines={1}
>
{error || "Couldn't send"}
</Text>
<Pressable
onPress={onRetry}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel="Retry sending comment"
>
<Text className="text-xs text-primary font-medium">Retry</Text>
</Pressable>
<Pressable
onPress={onDiscard}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel="Discard failed comment"
>
<Text className="text-xs text-muted-foreground font-medium">
Discard
</Text>
</Pressable>
</View>
);
}

View File

@@ -0,0 +1,247 @@
/**
* Long-press handler for a comment bubble. Exposes `onLongPress` (drives a
* native iOS ActionSheetIOS) and `isPressed` (drives the caller's highlight
* ring while the sheet is on screen).
*
* iOS-native first per apps/mobile/CLAUDE.md §UI components → waterfall step
* 1: `ActionSheetIOS.showActionSheetWithOptions`. Zero custom layout, zero
* animation, zero overflow math, zero new deps.
*
* Item set (conditional, mirrors web's comment context menu):
* Reply (stub) · React… (opens nested sheet) · Copy · Select Text ·
* Copy Link · Resolve/Unresolve Thread (root only) · Delete (own only) ·
* Cancel
*
* The nested React… sheet (5 quick emojis + More reactions… + Cancel) is
* fired from INSIDE the outer sheet's completion callback rather than
* inline, because iOS will refuse to present a second ActionSheet while the
* first is still dismissing — the callback runs after dismissal completes.
*/
import { useCallback, useState } from "react";
import { ActionSheetIOS, Alert } from "react-native";
import { router } from "expo-router";
import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import type { Reaction, TimelineEntry } from "@multica/core/types";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useCommentSelectStore } from "@/data/comment-select-store";
import { useReplyTargetStore } from "@/data/stores/reply-target-store";
import { useActorLookup } from "@/data/use-actor-name";
import {
useDeleteComment,
useResolveComment,
useToggleCommentReaction,
} from "@/data/mutations/issues";
import { QUICK_EMOJIS } from "@/lib/quick-emojis";
const QUICK_ROW_SIZE = 5;
export function useCommentLongPress(
entry: TimelineEntry,
issueId: string,
issueIdentifier: string | undefined,
): { onLongPress: () => void; isPressed: boolean } {
const [isPressed, setIsPressed] = useState(false);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const userId = useAuthStore((s) => s.user?.id);
const toggleReaction = useToggleCommentReaction(issueId);
const deleteComment = useDeleteComment(issueId);
const resolveComment = useResolveComment(issueId);
const { getName } = useActorLookup();
const onLongPress = useCallback(() => {
const isOwn = entry.actor_type === "member" && entry.actor_id === userId;
const isRoot = !entry.parent_id;
const resolved = !!entry.resolved_at;
const hasContent = !!entry.content;
const webUrl = process.env.EXPO_PUBLIC_WEB_URL;
const canCopyLink = !!(webUrl && wsSlug && issueIdentifier);
const reactions = (entry.reactions ?? []) as Reaction[];
Haptics.selectionAsync().catch(() => {});
setIsPressed(true);
type Action =
| { kind: "reply" }
| { kind: "react" }
| { kind: "copy" }
| { kind: "select" }
| { kind: "copyLink" }
| { kind: "resolve" }
| { kind: "delete" }
| { kind: "cancel" };
const options: string[] = [];
const actions: Action[] = [];
const push = (label: string, action: Action) => {
options.push(label);
actions.push(action);
};
push("Reply", { kind: "reply" });
push("React…", { kind: "react" });
if (hasContent) {
push("Copy", { kind: "copy" });
push("Select Text", { kind: "select" });
}
if (canCopyLink) push("Copy Link", { kind: "copyLink" });
if (isRoot) {
push(resolved ? "Unresolve Thread" : "Resolve Thread", {
kind: "resolve",
});
}
if (isOwn) push("Delete", { kind: "delete" });
push("Cancel", { kind: "cancel" });
const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOwn
? actions.findIndex((a) => a.kind === "delete")
: undefined;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex,
...(destructiveButtonIndex !== undefined &&
destructiveButtonIndex >= 0
? { destructiveButtonIndex }
: {}),
},
(i) => {
setIsPressed(false);
const action = actions[i];
if (!action || action.kind === "cancel") return;
switch (action.kind) {
case "reply": {
// Set the reply target — the InlineCommentComposer subscribes
// to this store, auto-expands, and threads the next submit
// under entry.id via useCreateComment's `parentId`.
const actorName = getName(
entry.actor_type as "member" | "agent" | null | undefined,
entry.actor_id,
);
useReplyTargetStore.getState().setTarget({
commentId: entry.id,
actorName: actorName || "comment",
preview: entry.content ?? "",
});
return;
}
case "react":
// Present the nested React sheet from inside this completion
// callback — see file header for why.
presentReactSheet({
entry,
reactions,
userId,
wsSlug,
issueId,
toggle: (emoji, existing) =>
toggleReaction.mutate({
commentId: entry.id,
emoji,
existing,
}),
});
return;
case "copy":
if (entry.content) {
Clipboard.setStringAsync(entry.content);
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success,
).catch(() => {});
}
return;
case "select":
useCommentSelectStore.getState().setSelecting(entry.id);
return;
case "copyLink": {
if (!canCopyLink) return;
const url = `${webUrl}/${wsSlug}/issue/${issueIdentifier}#comment-${entry.id}`;
Clipboard.setStringAsync(url);
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success,
).catch(() => {});
return;
}
case "resolve":
resolveComment.mutate({
commentId: entry.id,
resolved: !entry.resolved_at,
});
return;
case "delete":
Alert.alert(
"Delete comment?",
"This comment will be permanently deleted. Replies in the thread will also be removed. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteComment.mutate(entry.id),
},
],
);
return;
}
},
);
}, [
entry,
issueId,
issueIdentifier,
userId,
wsSlug,
toggleReaction,
deleteComment,
resolveComment,
]);
return { onLongPress, isPressed };
}
function presentReactSheet(args: {
entry: TimelineEntry;
reactions: Reaction[];
userId: string | undefined;
wsSlug: string | null;
issueId: string;
toggle: (emoji: string, existing: Reaction | undefined) => void;
}) {
const { entry, reactions, userId, wsSlug, issueId, toggle } = args;
const emojis = QUICK_EMOJIS.slice(0, QUICK_ROW_SIZE);
const options = [...emojis, "More reactions…", "Cancel"];
const cancelButtonIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{ options, cancelButtonIndex },
(i) => {
if (i === cancelButtonIndex) return;
if (i === emojis.length) {
if (!wsSlug) return;
router.push({
pathname:
"/[workspace]/issue/[id]/comment/[commentId]/emoji-picker",
params: {
workspace: wsSlug,
id: issueId,
commentId: entry.id,
},
});
return;
}
const emoji = emojis[i];
if (!emoji) return;
const existing = reactions.find(
(r) =>
r.emoji === emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
toggle(emoji, existing);
},
);
}

View File

@@ -0,0 +1,248 @@
/**
* Unified chip row for the comment composer's pending pickables.
*
* Three chip kinds share the same capsule shape (icon + name + remove ×):
*
* - mention → `@<name>` (or `@all` for the workspace-wide pick). Tap is a
* no-op; only the × button removes. Drives the comment's
* mention markdown header on submit.
* - image → filename + image icon. Tap opens the lightbox using the
* LOCAL file:// uri so completed and uploading items both
* preview without waiting for the server URL.
* - file → filename + document icon. Tap opens the canonical
* download_url in Safari once the upload completed; before
* completion the tap is a no-op.
*
* Capsule (not thumbnail) by design: the previous version showed an actual
* image preview inside a 64x64 card. The user feedback was that the preview
* pulled the eye away from the input — the row should be a "what did I
* attach" summary, not a visual gallery. Click-to-zoom satisfies the
* "I want to verify it's the right image" need without competing with
* @ and file chips for visual weight.
*
* Lives above the TextInput (between the reply-target chip and the input
* itself). The composer measures whether the row is non-empty to decide
* whether to render this component at all — empty composer keeps the
* vertical footprint minimal.
*/
import { useMemo } from "react";
import { ActivityIndicator, Linking, Pressable, ScrollView, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useLightbox } from "@/lib/markdown/lightbox-provider";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { Text } from "@/components/ui/text";
/** Mention chip data — composer-local state. No store, no cross-route
* sharing. The composer owns the array and passes it in. */
export type MentionChipType = "member" | "agent" | "squad" | "all" | "issue";
export interface MentionChip {
type: MentionChipType;
/** UUID for member/agent/squad/issue; literal "all" for @all. */
id: string;
/** Display name without leading `@`. For type "issue" this stores the
* human identifier (e.g. "MUL-123"), which is what the chip + the
* serialised markdown link both surface (matches web's
* packages/views/editor/extensions/mention-extension.ts:67-74 — issues
* drop the leading `@`). */
name: string;
}
export type ComposerAttachmentStatus = "uploading" | "completed" | "failed";
export interface ComposerAttachmentItem {
/** Stable local id assigned by the composer when the user picked. Used as
* the React key AND as the lookup id for status transitions. We don't use
* the server-returned `id` because it doesn't exist yet during upload. */
localId: string;
/** `file://...` from expo-image-picker / expo-document-picker. Source of
* truth for lightbox preview even post-upload — on-device cache. */
localUri: string;
filename: string;
mimeType: string;
status: ComposerAttachmentStatus;
/** Populated when status === "completed" — the server-side attachment id
* that the comment mutation will reference via `attachmentIds`. */
id?: string;
/** Populated when status === "completed" — canonical `mc://file/<id>`
* URL the server returns. The composer submits by id, not url; the
* field is kept for inline-insert affordances or debugging. */
url?: string;
/** Populated when status === "completed" — signed HTTPS link to open in
* Safari for file chips. Mirrors web's "download" path. */
downloadUrl?: string;
/** Populated when status === "failed" — short human-readable error. */
error?: string;
}
interface Props {
mentions: MentionChip[];
attachments: ComposerAttachmentItem[];
onRemoveMention: (type: MentionChipType, id: string) => void;
onRemoveAttachment: (localId: string) => void;
onRetryAttachment?: (localId: string) => void;
}
export function ComposerAttachmentRow({
mentions,
attachments,
onRemoveMention,
onRemoveAttachment,
onRetryAttachment,
}: Props) {
if (mentions.length === 0 && attachments.length === 0) return null;
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 6, paddingHorizontal: 2, paddingVertical: 2 }}
keyboardShouldPersistTaps="handled"
>
{mentions.map((m) => (
<MentionChipView
key={`m:${m.type}:${m.id}`}
mention={m}
onRemove={onRemoveMention}
/>
))}
{attachments.map((a) => (
<AttachmentChipView
key={a.localId}
item={a}
onRemove={onRemoveAttachment}
onRetry={onRetryAttachment}
/>
))}
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Mention chip — small capsule, no tap (only × removes).
// ---------------------------------------------------------------------------
function MentionChipView({
mention,
onRemove,
}: {
mention: MentionChip;
onRemove: (type: MentionChipType, id: string) => void;
}) {
const { colorScheme } = useColorScheme();
const theme = THEME[colorScheme];
// Icon picks: @all → people; issue → git-branch (matches web's status icon
// styling for issue mentions); else single-person glyph.
const iconName =
mention.type === "all"
? "people"
: mention.type === "issue"
? "git-branch-outline"
: "person";
// Issue chips show the bare identifier (e.g. "MUL-123") — no leading @.
// Mirrors how the serialized markdown link renders on web/desktop.
const label = mention.type === "issue" ? mention.name : `@${mention.name}`;
return (
<View className="flex-row items-center gap-1 h-7 px-2 rounded-full bg-primary/10">
<Ionicons name={iconName} size={12} color={theme.primary} />
<Text className="text-xs font-medium text-foreground">{label}</Text>
<Pressable
onPress={() => onRemove(mention.type, mention.id)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Remove mention ${mention.name}`}
className="h-4 w-4 items-center justify-center"
>
<Ionicons name="close" size={12} color={theme.mutedForeground} />
</Pressable>
</View>
);
}
// ---------------------------------------------------------------------------
// Attachment chip — image / file capsule with status overlay.
// ---------------------------------------------------------------------------
interface AttachmentChipProps {
item: ComposerAttachmentItem;
onRemove: (localId: string) => void;
onRetry?: (localId: string) => void;
}
function AttachmentChipView({ item, onRemove, onRetry }: AttachmentChipProps) {
const { colorScheme } = useColorScheme();
const theme = THEME[colorScheme];
const { open } = useLightbox();
const isImage = useMemo(
() => item.mimeType.startsWith("image/"),
[item.mimeType],
);
const onPress = () => {
if (item.status === "failed" && onRetry) {
onRetry(item.localId);
return;
}
if (item.status !== "completed") return;
if (isImage) {
// Prefer the local on-device file over the network URL — instant,
// no signed-URL round-trip, works the same pre/post upload.
open(item.localUri);
} else if (item.downloadUrl) {
void Linking.openURL(item.downloadUrl);
}
};
const iconName = item.status === "failed"
? "refresh"
: isImage
? "image-outline"
: "document-outline";
return (
<Pressable
onPress={onPress}
accessibilityRole={item.status === "failed" ? "button" : "image"}
accessibilityLabel={
item.status === "failed"
? `Retry upload of ${item.filename}`
: `Open ${item.filename}`
}
className="flex-row items-center gap-1 h-7 px-2 rounded-full bg-secondary active:opacity-80"
>
{item.status === "uploading" ? (
<ActivityIndicator size="small" color={theme.mutedForeground} />
) : (
<Ionicons
name={iconName}
size={12}
color={
item.status === "failed"
? theme.destructive
: theme.mutedForeground
}
/>
)}
<Text
className="text-xs text-foreground max-w-[120px]"
numberOfLines={1}
>
{item.filename}
</Text>
<Pressable
onPress={() => onRemove(item.localId)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Remove ${item.filename}`}
className="h-4 w-4 items-center justify-center"
>
<Ionicons name="close" size={12} color={theme.mutedForeground} />
</Pressable>
</Pressable>
);
}

View File

@@ -0,0 +1,138 @@
/**
* Bottom chip row for the new-issue form. Mirrors `attribute-row.tsx`'s
* visual pattern but operates on the `useNewIssueDraftStore` instead of an
* `issue` object + mutation. Tapping a chip pushes a formSheet picker
* route under `new-issue-picker/<field>` — the route reads/writes the same
* draft store, so the chip rehydrates automatically when the sheet
* dismisses.
*
* Why a draft store: the picker routes are siblings of new-issue.tsx in
* the Stack — they can't reach into the new-issue screen's local state.
* The draft store is the cross-screen channel.
*/
import { View } from "react-native";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { AttributeChip } from "@/components/issue/attribute-chip";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { ProjectIcon } from "@/components/ui/project-icon";
import { StatusIcon } from "@/components/ui/status-icon";
import { useActorLookup } from "@/data/use-actor-name";
import { useNewIssueDraftStore } from "@/data/stores/new-issue-draft-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { PRIORITY_LABEL, STATUS_LABEL } from "@/lib/issue-status";
/**
* Picker fields the new-issue draft form can open. Bound to a typed map
* of Expo Router pathnames so typos become compile errors (previously
* the call site used `as never` on a template string).
*/
type NewIssuePickerField =
| "status"
| "priority"
| "assignee"
| "project"
| "due-date";
const NEW_ISSUE_PICKER_PATHNAMES = {
status: "/[workspace]/new-issue-picker/status",
priority: "/[workspace]/new-issue-picker/priority",
assignee: "/[workspace]/new-issue-picker/assignee",
project: "/[workspace]/new-issue-picker/project",
"due-date": "/[workspace]/new-issue-picker/due-date",
} as const satisfies Record<NewIssuePickerField, string>;
export function CreateFormAttributeRow() {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const status = useNewIssueDraftStore((s) => s.status);
const priority = useNewIssueDraftStore((s) => s.priority);
const assignee = useNewIssueDraftStore((s) => s.assignee);
const dueDate = useNewIssueDraftStore((s) => s.dueDate);
const project = useNewIssueDraftStore((s) => s.project);
const { getName } = useActorLookup();
const assigneeLabel = assignee
? getName(assignee.type, assignee.id)
: "Assignee";
const priorityLabel =
priority === "none" ? "Priority" : PRIORITY_LABEL[priority];
const open = (field: NewIssuePickerField) => {
if (!wsSlug) return;
router.push({
pathname: NEW_ISSUE_PICKER_PATHNAMES[field],
params: { workspace: wsSlug },
});
};
return (
<View>
<View className="flex-row flex-wrap gap-2">
<AttributeChip
icon={<StatusIcon status={status} size={12} />}
label={STATUS_LABEL[status]}
variant="filled"
onPress={() => open("status")}
/>
<AttributeChip
icon={<PriorityIcon priority={priority} />}
label={priorityLabel}
variant={priority === "none" ? "dimmed" : "filled"}
onPress={() => open("priority")}
/>
<AttributeChip
icon={
assignee ? (
<ActorAvatar
type={assignee.type}
id={assignee.id}
size={16}
showPresence
/>
) : (
<Ionicons
name="person-circle-outline"
size={16}
color="#a1a1aa"
/>
)
}
label={assigneeLabel}
variant={assignee ? "filled" : "dimmed"}
onPress={() => open("assignee")}
/>
<AttributeChip
icon={
<Ionicons
name="calendar-outline"
size={14}
color={dueDate ? undefined : "#a1a1aa"}
/>
}
label={dueDate ? formatDueDate(dueDate) : "Due date"}
variant={dueDate ? "filled" : "dimmed"}
onPress={() => open("due-date")}
/>
<AttributeChip
icon={
project ? (
<ProjectIcon icon={project.icon} size="sm" />
) : (
<Ionicons name="folder-outline" size={14} color="#a1a1aa" />
)
}
label={project?.title ?? "Project"}
variant={project ? "filled" : "dimmed"}
onPress={() => open("project")}
/>
</View>
</View>
);
}
function formatDueDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "Due date";
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}

View File

@@ -0,0 +1,53 @@
/**
* Description input block shared by `new-issue.tsx` and `issue/[id]/edit.tsx`.
*
* Focus-tinted `rounded-2xl` container wrapping the `AutosizeTextArea` —
* matches the "write markdown body" treatment used by the comment composer
* so all three surfaces feel like the same control.
*
* Pure UI shell. The mention pipeline lives in the caller's `useMentionInput`
* instance, passed in as `description`. Callers also own the floating
* `MentionSuggestionBar` (it has to sit above the keyboard, outside the
* scroll view).
*/
import { useState } from "react";
import { View } from "react-native";
import { AutosizeTextArea } from "@/components/ui/autosize-textarea";
import { MIN_BODY_INPUT_HEIGHT_PX } from "@/components/ui/input-tokens";
import { cn } from "@/lib/utils";
import type { UseMentionInputReturn } from "@/lib/use-mention-input";
export function DescriptionField({
description,
disabled,
placeholder = "Description… (type @ to mention)",
}: {
description: UseMentionInputReturn;
disabled: boolean;
placeholder?: string;
}) {
const [focused, setFocused] = useState(false);
return (
<View
className={cn(
"rounded-2xl border px-3",
focused
? "border-primary/30 bg-secondary"
: "border-transparent bg-secondary/40",
)}
>
<AutosizeTextArea
value={description.text}
onChangeText={description.handlers.onChangeText}
selection={description.selection}
onSelectionChange={description.handlers.onSelectionChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholder}
className="py-2"
minHeight={MIN_BODY_INPUT_HEIGHT_PX}
editable={!disabled}
/>
</View>
);
}

View File

@@ -0,0 +1,74 @@
/**
* Inline issue-comment composer — thin wrapper around the shared
* `<MessageComposer>` with comment-specific wiring:
*
* - `onSubmit` → `useCreateComment(issueId).mutateAsync`
* - Reply target sourced from `useReplyTargetStore` (set by the
* comment long-press action sheet)
* - Mention picker path → `/[workspace]/mention-picker?mode=comment`
* - Upload context binds attachments to this issue
*
* All UI / state / chip plumbing lives in `MessageComposer`. The chat
* composer (`components/chat/chat-composer.tsx`) uses the same component
* with chat-mode props.
*/
import { useCallback } from "react";
import { useCreateComment } from "@/data/mutations/issues";
import { useReplyTargetStore } from "@/data/stores/reply-target-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { MessageComposer } from "@/components/composer/message-composer";
export function InlineCommentComposer({ issueId }: { issueId: string }) {
const createComment = useCreateComment(issueId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const replyTarget = useReplyTargetStore((s) => s.target);
const clearReplyTarget = useReplyTargetStore((s) => s.clear);
const onSubmit = useCallback(
async ({
content,
attachmentIds,
}: {
content: string;
attachmentIds: string[];
}) => {
try {
await createComment.mutateAsync({
content,
parentId: replyTarget?.commentId,
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
});
} catch (err) {
// Rethrow so MessageComposer's catch path restores text + chips.
// The optimistic timeline row stays with its inline
// Failed · Retry · Discard affordance.
throw err;
}
},
[createComment, replyTarget?.commentId],
);
return (
<MessageComposer
onSubmit={onSubmit}
mentionPickerPath={{
pathname: "/[workspace]/mention-picker",
params: { workspace: wsSlug ?? "", mode: "comment" },
}}
uploadContext={{ issueId }}
placeholder="Add a comment…"
pillLabel="Add a comment, @ to mention…"
pillIcon="chatbubble-ellipses-outline"
replyTarget={
replyTarget
? {
actorName: replyTarget.actorName,
preview: replyTarget.preview,
}
: null
}
onClearReplyTarget={clearReplyTarget}
expandTrigger={replyTarget?.commentId ?? null}
/>
);
}

View File

@@ -0,0 +1,48 @@
/**
* Description block. Renders markdown via the standalone mobile markdown
* renderer at apps/mobile/lib/markdown/. Empty / null descriptions show
* a muted "No description." placeholder rather than collapsing the block,
* so the layout above the timeline stays stable when the user adds a
* description later.
*
* Attachments are fetched per-issue so markdown can resolve `mc://file/<id>`
* image URIs into real `download_url` HTTPS endpoints — without this the
* iOS image loader doesn't understand the mc: scheme and the image fails.
* TanStack Query dedupes the request across this component and CommentCard
* (both call `issueAttachmentsOptions(wsId, issueId)`), so only one
* network roundtrip fires per issue.
*/
import { View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { Markdown } from "@/lib/markdown";
import { issueAttachmentsOptions } from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export function IssueDescription({
issueId,
description,
}: {
issueId: string;
description: string | null;
}) {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: attachments } = useQuery(
issueAttachmentsOptions(wsId, issueId),
);
if (!description || description.trim().length === 0) {
return (
<View className="px-4 pb-4">
<Text className="text-sm text-muted-foreground italic">
No description.
</Text>
</View>
);
}
return (
<View className="px-4 pb-4">
<Markdown content={description} attachments={attachments} />
</View>
);
}

View File

@@ -0,0 +1,35 @@
/**
* Slim header for the issue detail screen.
*
* Linear iOS-inspired layout:
* - identifier (MUL-NN) above as a small muted label
* - title in a large bold treatment
* - attribute chip row below (status / priority / assignee / labels /
* project / due date) — tappable, opens picker sheets
*
* The native iOS Stack header still renders `issue.identifier` as the
* navigation title; the body re-renders it more prominently per the
* reference screenshot.
*/
import { View } from "react-native";
import type { Issue } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { AttributeRow } from "./attribute-row";
import { AgentActivityRow } from "./agent-activity-row";
export function IssueHeaderCard({ issue }: { issue: Issue }) {
return (
<View className="px-4 pt-4 pb-3 gap-3">
<Text className="text-xs text-muted-foreground">{issue.identifier}</Text>
<Text className="text-2xl font-bold text-foreground">
{issue.title}
</Text>
{/* Activity row sits between title and attributes — it represents
* "who's doing this issue right now / who has done it" (dynamic),
* which is higher-IA than the static property chips below.
* Conditionally renders null when there are no tasks at all. */}
<AgentActivityRow issueId={issue.id} />
<AttributeRow issue={issue} />
</View>
);
}

Some files were not shown because too many files have changed in this diff Show More