mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
107 Commits
agent/lamb
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd36d60f1b | ||
|
|
90455abd8d | ||
|
|
9d5c023145 | ||
|
|
cfc652aa5f | ||
|
|
1c5e483b1c | ||
|
|
cd71b0fe05 | ||
|
|
8e9df90d32 | ||
|
|
6703072241 | ||
|
|
a8cda1bd96 | ||
|
|
be54e79f38 | ||
|
|
6261ea45fd | ||
|
|
077bc055f7 | ||
|
|
3df26ddd28 | ||
|
|
f59c34eea8 | ||
|
|
5f1f08e466 | ||
|
|
f0c32d5728 | ||
|
|
44ee74eb25 | ||
|
|
993cf550ad | ||
|
|
ba945c1141 | ||
|
|
3e1066a638 | ||
|
|
bfb7c85491 | ||
|
|
660e27b981 | ||
|
|
fd0fe1d08a | ||
|
|
c280fc0879 | ||
|
|
0339599ff6 | ||
|
|
a55c03a0b3 | ||
|
|
ed8f43867c | ||
|
|
d6fdd8d74e | ||
|
|
f2e6dc75bd | ||
|
|
0bb51ccd0e | ||
|
|
5bc77f2953 | ||
|
|
e0b756f515 | ||
|
|
a6f19380b2 | ||
|
|
c967ae0e0e | ||
|
|
1c91c2a3b2 | ||
|
|
fedd0f1694 | ||
|
|
5d9293b8d0 | ||
|
|
f0a6738ed9 | ||
|
|
eefc6cebaa | ||
|
|
38ea02e60c | ||
|
|
bc056cf0ea | ||
|
|
ba9714a364 | ||
|
|
46a29b1ebb | ||
|
|
a5582198ab | ||
|
|
7984606eed | ||
|
|
424f67f7cb | ||
|
|
295df8d928 | ||
|
|
5bacfd9742 | ||
|
|
b9602adabe | ||
|
|
74f4d5a8fc | ||
|
|
4ee5d5acdd | ||
|
|
41788d2728 | ||
|
|
fbd965e5bf | ||
|
|
cb90249eac | ||
|
|
af13d7ad3a | ||
|
|
cbd42dfcc4 | ||
|
|
adec90c621 | ||
|
|
ae530ef057 | ||
|
|
ab0228c2a1 | ||
|
|
e288eff2c5 | ||
|
|
29c2a5d18f | ||
|
|
81e8aa5812 | ||
|
|
0c767c0052 | ||
|
|
66c0464140 | ||
|
|
9a5d8a52f3 | ||
|
|
51b3c5291f | ||
|
|
51c6e90363 | ||
|
|
614dfae884 | ||
|
|
d0666138ec | ||
|
|
41cb91abd9 | ||
|
|
1c892aa3f9 | ||
|
|
65feb890b8 | ||
|
|
7e55813460 | ||
|
|
7f9e4e829d | ||
|
|
8a135d2982 | ||
|
|
83e90c9530 | ||
|
|
ef6a944063 | ||
|
|
ed2957ddf8 | ||
|
|
2f1f90c11a | ||
|
|
688dcb017c | ||
|
|
cf000d1e93 | ||
|
|
317bca40c1 | ||
|
|
8d4f4caf4a | ||
|
|
34f16e2c7a | ||
|
|
85e363370e | ||
|
|
b040165f4e | ||
|
|
dee5c7cf50 | ||
|
|
aeb284cbeb | ||
|
|
1f978bf1ec | ||
|
|
ffc0c5ab2e | ||
|
|
b7082a01f1 | ||
|
|
314e91fa6d | ||
|
|
68270e238e | ||
|
|
eaf8b14866 | ||
|
|
41753d17a2 | ||
|
|
edded77691 | ||
|
|
9d3b6e2241 | ||
|
|
2bec2221d2 | ||
|
|
292226f632 | ||
|
|
72339f347b | ||
|
|
fc8528d64d | ||
|
|
4a487adfeb | ||
|
|
e48f6a84d6 | ||
|
|
5b8303b83c | ||
|
|
071ffca034 | ||
|
|
2ad1cd8ff8 | ||
|
|
34988216ed |
27
.env.example
27
.env.example
@@ -21,14 +21,23 @@ APP_ENV=
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Optional aliases for the local/self-host backend port. If one is set, it
|
||||
# takes precedence over PORT in compose, Makefile, and installer helpers.
|
||||
# BACKEND_PORT=8080
|
||||
# API_PORT=8080
|
||||
# SERVER_PORT=8080
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
# HTTP request metrics start accumulating only when this listener is enabled.
|
||||
# METRICS_ADDR=127.0.0.1:9090
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
# Derived by Makefile / local scripts from the backend port.
|
||||
# Set explicitly only when the daemon reaches the API through a different URL.
|
||||
# MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when the app's public URL differs from local frontend.
|
||||
# MULTICA_APP_URL=http://localhost:3000
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers. Leave unset behind a same-origin reverse proxy or for plain
|
||||
@@ -91,7 +100,9 @@ SMTP_TLS_INSECURE=false
|
||||
# rebuild is needed.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when your OAuth callback URL differs from local frontend.
|
||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
@@ -121,7 +132,9 @@ COOKIE_DOMAIN=
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Derived by Makefile / local scripts from the backend port.
|
||||
# Set explicitly only when uploads are served through a different public URL.
|
||||
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
|
||||
# Security
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket connections.
|
||||
@@ -170,9 +183,11 @@ GITHUB_WEBHOOK_SECRET=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
# FRONTEND_ORIGIN=http://localhost:3000
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
# Only set explicitly if frontend and backend are on different domains.
|
||||
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_WS_URL=
|
||||
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -29,6 +29,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Test self-host env derivation
|
||||
run: bash scripts/selfhost-config.test.sh
|
||||
|
||||
- name: Verify reserved-slugs.ts is up to date
|
||||
# Re-runs the generator and fails on any drift from the
|
||||
# checked-in TypeScript output. The Go side embeds the JSON
|
||||
@@ -91,3 +94,20 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test ./...
|
||||
|
||||
installer:
|
||||
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
|
||||
# backend job so installer regressions surface independently, and
|
||||
# exercised on macOS too because the installer targets macOS/Homebrew
|
||||
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test shell installers
|
||||
run: bash scripts/install.test.sh
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
48
CLAUDE.md
48
CLAUDE.md
@@ -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.
|
||||
|
||||
@@ -328,7 +328,14 @@ multica issue list --full-id
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
|
||||
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
|
||||
|
||||
```bash
|
||||
multica issue list --metadata pipeline_status=waiting_review
|
||||
multica issue list --metadata pr_number=482 --metadata is_blocked=true
|
||||
```
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -373,9 +380,44 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
|
||||
### Comments
|
||||
|
||||
```bash
|
||||
# List comments
|
||||
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
|
||||
# long-running issues prefer one of the thread-aware reads below to keep
|
||||
# context windows tight.
|
||||
multica issue comment list <issue-id>
|
||||
|
||||
# Single thread (root + every descendant). Anchor may be the root itself
|
||||
# or any reply inside the thread — the server walks up to the root.
|
||||
multica issue comment list <issue-id> --thread <comment-id>
|
||||
|
||||
# Single thread, capped to the N most recent replies. The thread root is
|
||||
# always included (even with --tail 0), so an agent landing on a long
|
||||
# thread keeps the "what is this about" context without dragging hundreds
|
||||
# of replies into its prompt.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30
|
||||
|
||||
# Scroll older replies inside the same thread. --before / --before-id are
|
||||
# the reply cursor that the previous response emitted on stderr as
|
||||
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--before <ts> --before-id <reply-id>
|
||||
|
||||
# Most recently active threads (root + every descendant), grouped by
|
||||
# thread. Returns N complete conversational arcs, oldest-active first so
|
||||
# the freshest thread sits closest to "now" in an agent prompt.
|
||||
multica issue comment list <issue-id> --recent 20
|
||||
|
||||
# Scroll older threads. Under --recent, --before / --before-id are a
|
||||
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
|
||||
# `Next thread cursor: --before <ts> --before-id <root-id>`.
|
||||
multica issue comment list <issue-id> --recent 20 \
|
||||
--before <ts> --before-id <root-id>
|
||||
|
||||
# Incremental polling. Combines with --thread or --recent; filters out
|
||||
# replies created on or before <ts> from the page (the thread root is
|
||||
# exempt so the agent always gets context).
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--since <RFC3339-timestamp>
|
||||
|
||||
# Add a comment
|
||||
multica issue comment add <issue-id> --content "Looks good, merging now"
|
||||
|
||||
@@ -386,6 +428,56 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
**`--before` / `--before-id` semantics depend on the paging mode**, by
|
||||
design — same flag, different scope:
|
||||
|
||||
| Mode | What the cursor walks | stderr label |
|
||||
| --- | --- | --- |
|
||||
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
|
||||
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
|
||||
|
||||
Outside those two modes (`--thread` without `--tail`, or no `--thread`
|
||||
and no `--recent`) the cursor flags are rejected so they cannot silently
|
||||
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
|
||||
`X-Multica-Next-Before-Id`) only when an older page actually exists —
|
||||
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
|
||||
replies) intentionally return no cursor so callers stop paginating.
|
||||
|
||||
When `--since` is combined with `--recent` or `--thread --tail`, the
|
||||
server additionally suppresses the cursor once the cursor target itself
|
||||
is older than `since`. Older pages walk strictly older rows, so they
|
||||
cannot satisfy `> since` either — emitting a cursor there would just
|
||||
hand back root-only pages until the caller reaches the start of the
|
||||
thread / issue. Incremental polling stops at the first page whose
|
||||
cursor target falls before the watermark.
|
||||
|
||||
### Metadata
|
||||
|
||||
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
|
||||
|
||||
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
|
||||
|
||||
```bash
|
||||
# List every key on an issue
|
||||
multica issue metadata list <issue-id>
|
||||
|
||||
# Read a single key
|
||||
multica issue metadata get <issue-id> --key pipeline_status
|
||||
|
||||
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
|
||||
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
|
||||
multica issue metadata set <issue-id> --key pr_number --value 482
|
||||
multica issue metadata set <issue-id> --key is_blocked --value true
|
||||
|
||||
# Force a specific type when sniffing would pick the wrong one
|
||||
multica issue metadata set <issue-id> --key code --value 42 --type string
|
||||
|
||||
# Remove a key
|
||||
multica issue metadata delete <issue-id> --key pipeline_status
|
||||
```
|
||||
|
||||
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,6 +18,7 @@ ARG COMMIT=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM alpine:3.21
|
||||
@@ -29,6 +30,7 @@ WORKDIR /app
|
||||
COPY --from=builder /src/server/bin/server .
|
||||
COPY --from=builder /src/server/bin/multica .
|
||||
COPY --from=builder /src/server/bin/migrate .
|
||||
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
3
Makefile
3
Makefile
@@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
|
||||
POSTGRES_USER ?= multica
|
||||
POSTGRES_PASSWORD ?= multica
|
||||
POSTGRES_PORT ?= 5432
|
||||
PORT ?= 8080
|
||||
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
|
||||
FRONTEND_PORT ?= 3000
|
||||
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
|
||||
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
|
||||
@@ -21,6 +21,7 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
|
||||
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
|
||||
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
|
||||
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
|
||||
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
|
||||
|
||||
export
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Autopilots** — schedule recurring work for agents. Cron triggers, webhooks, or manual runs — each autopilot creates the issue and routes it to an agent automatically, so daily standups, weekly reports, and periodic audits run themselves.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
|
||||
@@ -187,3 +188,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.
|
||||
|
||||
@@ -57,6 +57,7 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **Squads(小队)** — 把多个 Agent(以及人类成员)组合成由 leader agent 带队的小队,直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **自动化(Autopilots)** — 为 Agent 安排周期性工作。定时(Cron)、Webhook 或手动触发,自动化会自动创建 Issue 并分配给 Agent——日报、周报、定期巡检都能让它自己跑起来。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
|
||||
@@ -171,6 +172,8 @@ make start
|
||||
|
||||
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
iOS 移动端代码位于 [`apps/mobile/`](apps/mobile/),自己编译装到手机的方法见 [README](apps/mobile/README.md)。
|
||||
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
[Modified Apache 2.0 (with commercial restrictions)](LICENSE)
|
||||
|
||||
175
SELF_HOSTING.md
175
SELF_HOSTING.md
@@ -135,6 +135,181 @@ multica daemon status
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent — it will pick up the task automatically
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes Deployment (Alternative)
|
||||
|
||||
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the Helm chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
|
||||
|
||||
The chart creates the following resources in the target namespace:
|
||||
|
||||
- `multica-postgres` — `pgvector/pgvector:pg17` backed by a 10Gi PVC
|
||||
- `multica-backend` — Go API/WS server backed by a 5Gi uploads PVC
|
||||
- `multica-frontend` — Next.js standalone server
|
||||
- Two `Ingress` resources: one for the web host, one for the backend host
|
||||
- `multica-config` ConfigMap (rendered from `values.yaml`)
|
||||
|
||||
The `multica-secrets` Secret is **not** managed by the chart — you create it once with `kubectl` so real values never need to land in git.
|
||||
|
||||
> **One release per namespace:** the prebuilt `multica-web` image bakes `REMOTE_API_URL=http://backend:8080` at build time, so the chart ships an ExternalName Service literally named `backend`. Because that name is unprefixed, you can run only one Multica release per namespace, and `helm install` will fail if a `Service/backend` already exists there (pass `--take-ownership`, or use a dedicated namespace). If you build a web image with a patched `REMOTE_API_URL`, set `frontend.compatibility.backendAlias: false` to drop the alias.
|
||||
|
||||
> **Prerequisites:** `kubectl` and `helm` (v3.13+ for `--take-ownership`, or v4+) configured for the target cluster, an Ingress controller (Traefik / NGINX), and a default StorageClass.
|
||||
|
||||
### Step 1 — Point hostnames at the cluster
|
||||
|
||||
The chart defaults to `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Pick one of:
|
||||
|
||||
- **`/etc/hosts`** on every machine that needs access (developer laptops + the machine running the daemon):
|
||||
|
||||
```text
|
||||
192.168.1.206 multica.dev.lan api.multica.dev.lan
|
||||
```
|
||||
|
||||
Replace `192.168.1.206` with any node IP where your Ingress controller's Service is reachable.
|
||||
|
||||
- **Local DNS** (Pi-hole, Unbound, etc.): add A records for both hostnames pointing at the cluster Ingress IP.
|
||||
|
||||
To use different hostnames, override the matching values at install time (see [Step 4](#step-4--install-the-chart)) — `ingress.frontend.host`, `ingress.backend.host`, plus `backend.config.appUrl`, `backend.config.frontendOrigin`, `backend.config.localUploadBaseUrl`, and `backend.config.googleRedirectUri`.
|
||||
|
||||
### Step 2 — Create the namespace
|
||||
|
||||
```bash
|
||||
kubectl create namespace multica
|
||||
```
|
||||
|
||||
### Step 3 — Create the `multica-secrets` Secret
|
||||
|
||||
The chart references this Secret by name. Create it once with random values:
|
||||
|
||||
```bash
|
||||
kubectl -n multica create secret generic multica-secrets \
|
||||
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
|
||||
--from-literal=RESEND_API_KEY="" \
|
||||
--from-literal=GOOGLE_CLIENT_SECRET="" \
|
||||
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
|
||||
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
|
||||
```
|
||||
|
||||
Leave optional values empty for now — you can fill them in later (see [Step 5 — Log In](#step-5--log-in)).
|
||||
|
||||
### Step 4 — Install the chart
|
||||
|
||||
```bash
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
```
|
||||
|
||||
To override defaults, copy `deploy/helm/multica/values.yaml`, edit it, and pass it with `-f`:
|
||||
|
||||
```bash
|
||||
cp deploy/helm/multica/values.yaml my-values.yaml
|
||||
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
|
||||
helm install multica deploy/helm/multica -n multica -f my-values.yaml
|
||||
```
|
||||
|
||||
Watch the pods come up:
|
||||
|
||||
```bash
|
||||
kubectl -n multica get pods -w
|
||||
```
|
||||
|
||||
On a cold cluster the backend can sit `Running` but not `Ready` for a few minutes while it waits on PostgreSQL and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once the backend reports `Ready`, migrations have completed and `/healthz` returns OK:
|
||||
|
||||
```bash
|
||||
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
|
||||
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
```
|
||||
|
||||
Then open http://multica.dev.lan in your browser.
|
||||
|
||||
### Step 5 — Log In
|
||||
|
||||
The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.config.appEnv`), and there is no fixed verification code by default. Pick one of the following to log in — the same three options as the Docker setup:
|
||||
|
||||
- **Recommended (production):** patch the Secret with a real Resend key, then restart the backend:
|
||||
|
||||
```bash
|
||||
kubectl -n multica patch secret multica-secrets --type=merge \
|
||||
-p '{"stringData":{"RESEND_API_KEY":"re_xxx"}}'
|
||||
kubectl -n multica rollout restart deploy/multica-backend
|
||||
```
|
||||
|
||||
Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
|
||||
- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing.
|
||||
|
||||
```bash
|
||||
kubectl -n multica logs -f deploy/multica-backend | grep "Verification code"
|
||||
```
|
||||
|
||||
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
```bash
|
||||
helm upgrade multica deploy/helm/multica -n multica \
|
||||
-f my-values.yaml --set backend.config.appEnv=development
|
||||
kubectl -n multica patch secret multica-secrets --type=merge \
|
||||
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
|
||||
kubectl -n multica rollout restart deploy/multica-backend
|
||||
```
|
||||
|
||||
`ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml`. After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
|
||||
### Step 6 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine, not in the cluster. Install the CLI and an AI agent as in [Step 3](#step-3--install-cli--start-daemon) above, then point the CLI at your Ingress hostnames:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url http://api.multica.dev.lan \
|
||||
--app-url http://multica.dev.lan
|
||||
```
|
||||
|
||||
Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entries from [Step 1](#step-1--point-hostnames-at-the-cluster).
|
||||
|
||||
### Updating
|
||||
|
||||
To pull the latest images without changing the chart version:
|
||||
|
||||
```bash
|
||||
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
|
||||
```
|
||||
|
||||
To pin a specific Multica release, set the image tags in your values file:
|
||||
|
||||
```yaml
|
||||
images:
|
||||
backend:
|
||||
tag: v0.2.4
|
||||
frontend:
|
||||
tag: v0.2.4
|
||||
```
|
||||
|
||||
Then upgrade:
|
||||
|
||||
```bash
|
||||
helm upgrade multica deploy/helm/multica -n multica -f my-values.yaml
|
||||
```
|
||||
|
||||
To roll back if an upgrade goes sideways:
|
||||
|
||||
```bash
|
||||
helm -n multica rollback multica
|
||||
```
|
||||
|
||||
### Tearing down
|
||||
|
||||
```bash
|
||||
# Remove the workloads but keep the PVCs and the Secret
|
||||
helm -n multica uninstall multica
|
||||
|
||||
# Wipe everything, including PostgreSQL data and uploads
|
||||
kubectl delete namespace multica
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stopping Services
|
||||
|
||||
If you installed via the install script:
|
||||
|
||||
@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
import { installNavigationGestures } from "./navigation-gestures";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
@@ -252,6 +253,7 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
installNavigationGestures(mainWindow);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
|
||||
60
apps/desktop/src/main/navigation-gestures.test.ts
Normal file
60
apps/desktop/src/main/navigation-gestures.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { BrowserWindow } from "electron";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NAVIGATION_GESTURE_CHANNEL } from "../shared/navigation-gestures";
|
||||
import { installNavigationGestures } from "./navigation-gestures";
|
||||
|
||||
function makeWindow() {
|
||||
let swipeHandler:
|
||||
| ((event: unknown, direction: string) => void)
|
||||
| undefined;
|
||||
|
||||
const win = {
|
||||
on: vi.fn(
|
||||
(event: string, handler: (event: unknown, direction: string) => void) => {
|
||||
if (event === "swipe") swipeHandler = handler;
|
||||
return win;
|
||||
},
|
||||
),
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
win: win as unknown as BrowserWindow,
|
||||
send: win.webContents.send,
|
||||
emitSwipe: (direction: string) => swipeHandler?.({}, direction),
|
||||
};
|
||||
}
|
||||
|
||||
describe("installNavigationGestures", () => {
|
||||
it("registers macOS swipe navigation", () => {
|
||||
const { win, send, emitSwipe } = makeWindow();
|
||||
|
||||
installNavigationGestures(win, "darwin");
|
||||
|
||||
emitSwipe("right");
|
||||
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "back");
|
||||
|
||||
emitSwipe("left");
|
||||
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "forward");
|
||||
});
|
||||
|
||||
it("ignores non-horizontal swipe directions", () => {
|
||||
const { win, send, emitSwipe } = makeWindow();
|
||||
|
||||
installNavigationGestures(win, "darwin");
|
||||
emitSwipe("up");
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not register on non-mac platforms", () => {
|
||||
const { win, send, emitSwipe } = makeWindow();
|
||||
|
||||
installNavigationGestures(win, "linux");
|
||||
emitSwipe("right");
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
18
apps/desktop/src/main/navigation-gestures.ts
Normal file
18
apps/desktop/src/main/navigation-gestures.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { BrowserWindow } from "electron";
|
||||
import {
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
navigationGestureFromSwipe,
|
||||
} from "../shared/navigation-gestures";
|
||||
|
||||
export function installNavigationGestures(
|
||||
win: BrowserWindow,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): void {
|
||||
if (platform !== "darwin") return;
|
||||
|
||||
win.on("swipe", (_event, direction) => {
|
||||
const gesture = navigationGestureFromSwipe(direction);
|
||||
if (!gesture) return;
|
||||
win.webContents.send(NAVIGATION_GESTURE_CHANNEL, gesture);
|
||||
});
|
||||
}
|
||||
3
apps/desktop/src/preload/index.d.ts
vendored
3
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -42,6 +43,8 @@ interface DesktopAPI {
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
type NavigationGesture,
|
||||
} from "../shared/navigation-gestures";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
@@ -141,6 +146,16 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener("inbox:open", handler);
|
||||
};
|
||||
},
|
||||
/** Listen for native macOS back/forward swipe gestures. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, gesture: unknown) => {
|
||||
if (isNavigationGesture(gesture)) callback(gesture);
|
||||
};
|
||||
ipcRenderer.on(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { pickLocale } from "@multica/core/i18n";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWelcomeStore } from "@multica/core/onboarding";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
@@ -118,25 +120,31 @@ function AppContent() {
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
|
||||
// judgment in callback / login:
|
||||
// un-onboarded:
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// already onboarded:
|
||||
// zero workspaces → /workspaces/new overlay
|
||||
// ≥1 workspaces → no overlay, fall through to dashboard
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web layout
|
||||
// hard gate via overlays (desktop has no URL bar, so we open the
|
||||
// onboarding overlay instead of router.replace):
|
||||
// onboarded + has workspace → no overlay, dashboard
|
||||
// un-onboarded (any wsCount):
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// onboarded + no workspace → /workspaces/new overlay
|
||||
//
|
||||
// The "un-onboarded but in workspace" state is now physically impossible
|
||||
// because backend transactions atomically set onboarded_at when a user
|
||||
// joins the `member` table. Anyone with workspaces is by definition
|
||||
// onboarded.
|
||||
// V3 invariant: `onboarded_at != null` is the only path into the
|
||||
// dashboard. CreateWorkspace does not mark onboarded; only Step 3's
|
||||
// CompleteOnboarding (and AcceptInvitation) flip the flag. A user who
|
||||
// somehow has a workspace but no onboarded mark must be sent back to
|
||||
// /onboarding — we also clear the active workspace so the dashboard
|
||||
// doesn't render under the overlay with stale workspace context.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return undefined;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (hasOnboarded && wsCount > 0) return undefined;
|
||||
if (!hasOnboarded) {
|
||||
// Stale workspace context (if any) would leak X-Workspace-Slug
|
||||
// headers into onboarding-time API calls. Clear it before opening
|
||||
// the overlay.
|
||||
setCurrentWorkspace(null, null);
|
||||
// Look up pending invitations by email. Network blip is non-fatal —
|
||||
// fall through to onboarding so the user isn't stuck on a blank
|
||||
// window. The sidebar's pending-invitations dropdown will surface
|
||||
@@ -258,6 +266,9 @@ function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
async function handleDaemonLogout() {
|
||||
useTabStore.getState().reset();
|
||||
useWindowOverlayStore.getState().close();
|
||||
// Drop any post-onboarding welcome signal so user B logging in next
|
||||
// doesn't inherit user A's pending modal state.
|
||||
useWelcomeStore.getState().reset();
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
|
||||
@@ -54,6 +54,20 @@ function SidebarTopBar() {
|
||||
);
|
||||
}
|
||||
|
||||
function useNativeNavigationGestures() {
|
||||
const { goBack, goForward } = useTabHistory();
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onNavigationGesture((gesture) => {
|
||||
if (gesture === "back") {
|
||||
goBack();
|
||||
} else {
|
||||
goForward();
|
||||
}
|
||||
});
|
||||
}, [goBack, goForward]);
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is not occupying main-flow width — either user-collapsed (offcanvas) or
|
||||
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
|
||||
@@ -132,6 +146,7 @@ function DesktopInboxBridge() {
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
useNativeNavigationGestures();
|
||||
|
||||
// Reactive read of current workspace slug from the platform singleton.
|
||||
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
|
||||
|
||||
@@ -19,10 +19,28 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
// Remember the last known daemonId/deviceName. After the daemon is
|
||||
// stopped, `status.daemonId` goes back to undefined — without this
|
||||
// sticky cache the local row would either disappear or get reclassified
|
||||
// as a remote machine (since `isCurrent` requires a daemonId match),
|
||||
// taking the Start button with it.
|
||||
const [lastIdentity, setLastIdentity] = useState<{
|
||||
daemonId: string | null;
|
||||
deviceName: string | null;
|
||||
}>({ daemonId: null, deviceName: null });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
const apply = (s: DaemonStatus) => {
|
||||
setStatus(s);
|
||||
if (s.daemonId) {
|
||||
setLastIdentity({
|
||||
daemonId: s.daemonId,
|
||||
deviceName: s.deviceName ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.daemonAPI.getStatus().then(apply);
|
||||
return window.daemonAPI.onStatusChange(apply);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
@@ -32,9 +50,14 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
localDaemonId={status.daemonId ?? null}
|
||||
localMachineName={status.deviceName ?? null}
|
||||
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
|
||||
localMachineName={status.deviceName ?? lastIdentity.deviceName}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
// Desktop owns a local machine for the lifetime of the app, even
|
||||
// while the daemon is stopped or hasn't registered yet. The shared
|
||||
// page synthesizes a placeholder local row when no real runtime
|
||||
// matches, so the Start button is always reachable.
|
||||
hasLocalMachine
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
151
apps/desktop/src/renderer/src/components/tab-bar.test.tsx
Normal file
151
apps/desktop/src/renderer/src/components/tab-bar.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, within } from "@testing-library/react";
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
togglePin: vi.fn<(tabId: string) => void>(),
|
||||
closeTab: vi.fn<(tabId: string) => void>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
moveTab: vi.fn<(from: number, to: number) => void>(),
|
||||
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
togglePin: state.togglePin,
|
||||
closeTab: state.closeTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
moveTab: state.moveTab,
|
||||
addTab: state.addTab,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const useActiveGroup = () =>
|
||||
state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
|
||||
: null;
|
||||
const resolveRouteIcon = () => "ListTodo";
|
||||
return { useTabStore, useActiveGroup, resolveRouteIcon };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: {
|
||||
workspace: (slug: string) => ({
|
||||
issues: () => `/${slug}/issues`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { TabBar } from "./tab-bar";
|
||||
|
||||
function reset() {
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.togglePin.mockReset();
|
||||
state.closeTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.moveTab.mockReset();
|
||||
state.addTab.mockReset();
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("TabBar hover action buttons", () => {
|
||||
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
|
||||
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clicking the Pin button calls togglePin for the tab", () => {
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
const pinButtons = getAllByLabelText("Pin tab");
|
||||
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tB");
|
||||
});
|
||||
|
||||
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
fireEvent.click(getByLabelText("Unpin tab"));
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tA");
|
||||
});
|
||||
|
||||
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { queryAllByLabelText } = render(<TabBar />);
|
||||
// Only the unpinned tab exposes a Close affordance — pinned tab requires
|
||||
// explicit Unpin first (RFC §3 D3c FINAL).
|
||||
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
const unpinnedTab = getByLabelText("Projects");
|
||||
// lucide-react renders the icon name into the class list. The leading
|
||||
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
|
||||
// so we qualify on size to avoid matching the action glyph.
|
||||
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
|
||||
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
|
||||
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
|
||||
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
Pin,
|
||||
PinOff,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -28,8 +31,20 @@ import {
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import {
|
||||
useTabStore,
|
||||
useActiveGroup,
|
||||
resolveRouteIcon,
|
||||
type Tab,
|
||||
} from "@/stores/tab-store";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -42,9 +57,23 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Settings,
|
||||
};
|
||||
|
||||
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
function SortableTabItem({
|
||||
tab,
|
||||
isActive,
|
||||
isOnly,
|
||||
}: {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
/**
|
||||
* True iff this is the only tab in the workspace. Hiding X on the last
|
||||
* tab matches existing behavior and avoids the surprise of the store's
|
||||
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
|
||||
*/
|
||||
isOnly: boolean;
|
||||
}) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const togglePin = useTabStore((s) => s.togglePin);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
@@ -55,7 +84,11 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
|
||||
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
|
||||
// present in the title, and this avoids a hard left accent border that read
|
||||
// as visually heavy in light mode.
|
||||
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -74,17 +107,30 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
closeTab(tab.id);
|
||||
};
|
||||
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
const handleTogglePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
togglePin(tab.id);
|
||||
};
|
||||
|
||||
const stopDragOnAction = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
|
||||
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
|
||||
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
|
||||
// reachable via the hover action button below and the right-click menu.
|
||||
const showCloseButton = !tab.pinned && !isOnly;
|
||||
|
||||
const tabButton = (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
|
||||
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
@@ -94,7 +140,7 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
|
||||
<span
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
|
||||
style={{
|
||||
@@ -104,10 +150,22 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
{!isOnly && (
|
||||
<span
|
||||
onClick={handleTogglePin}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
title={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
|
||||
</span>
|
||||
{showCloseButton && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={stopDragOnClose}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label="Close tab"
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
@@ -115,6 +173,36 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={tabButton} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => togglePin(tab.id)}>
|
||||
{tab.pinned ? (
|
||||
<>
|
||||
<PinOff />
|
||||
Unpin tab
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin />
|
||||
Pin tab
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={tab.pinned || isOnly}
|
||||
onClick={() => closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
Close tab
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTabButton() {
|
||||
@@ -155,12 +243,17 @@ export function TabBar() {
|
||||
const tabs = group?.tabs ?? [];
|
||||
const activeTabId = group?.activeTabId ?? "";
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
const pinnedCount = tabs.filter((t) => t.pinned).length;
|
||||
const unpinnedCount = tabs.length - pinnedCount;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
// The store clamps the destination to within the source tab's zone
|
||||
// (pinned vs unpinned), so this call is safe even when the user tries
|
||||
// to drag across the boundary — the tab will land at the boundary.
|
||||
if (from !== -1 && to !== -1) moveTab(from, to);
|
||||
};
|
||||
|
||||
@@ -173,13 +266,22 @@ export function TabBar() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
<SortableTabItem
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
{tab.pinned &&
|
||||
index === pinnedCount - 1 &&
|
||||
unpinnedCount > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-1 h-4 w-px bg-border"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RouterProvider } from "react-router-dom";
|
||||
import { useActiveGroup } from "@/stores/tab-store";
|
||||
import { TabNavigationProvider } from "@/platform/navigation";
|
||||
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
|
||||
import { useTabScrollRestore } from "@/hooks/use-tab-scroll-restore";
|
||||
import type { Tab } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,28 @@ function TabRouterInner({ tab }: { tab: Tab }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a tab's subtree so its scroll position survives the round trip
|
||||
* through `<Activity mode="hidden">`. Lives inside Activity so the hook's
|
||||
* effects cycle with the tab's visibility — see `useTabScrollRestore` for
|
||||
* the mechanism. `display: contents` keeps the wrapper transparent to
|
||||
* the surrounding flex layout.
|
||||
*/
|
||||
function TabScrollRestoreWrapper({
|
||||
tabPath,
|
||||
children,
|
||||
}: {
|
||||
tabPath: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useTabScrollRestore(tabPath);
|
||||
return (
|
||||
<div ref={ref} style={{ display: "contents" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the active workspace's tabs using Activity for state preservation.
|
||||
* Only the active tab is visible; hidden tabs keep their DOM and React state.
|
||||
@@ -44,10 +67,12 @@ export function TabContent() {
|
||||
key={tab.id}
|
||||
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
|
||||
>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tab={tab} />
|
||||
</TabNavigationProvider>
|
||||
<TabScrollRestoreWrapper tabPath={tab.path}>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tab={tab} />
|
||||
</TabNavigationProvider>
|
||||
</TabScrollRestoreWrapper>
|
||||
</Activity>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
@@ -10,6 +11,7 @@ type CheckState =
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const { t } = useT("settings");
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
@@ -29,17 +31,15 @@ export function UpdatesSettingsTab() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<h2 className="text-lg font-semibold">{t(($) => $.desktop.updates.title)}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch, downloading them in the background. You'll
|
||||
be prompted to restart once an update is ready.
|
||||
{t(($) => $.desktop.updates.description)}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm font-medium">{t(($) => $.desktop.updates.current_version)}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
@@ -48,23 +48,20 @@ export function UpdatesSettingsTab() {
|
||||
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm font-medium">{t(($) => $.desktop.updates.check_section_title)}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates download in the background and show a
|
||||
restart prompt when ready.
|
||||
{t(($) => $.desktop.updates.check_section_description)}
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version.
|
||||
{t(($) => $.desktop.updates.up_to_date)}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is downloading in the background —
|
||||
you'll be notified when it's ready to install.
|
||||
{t(($) => $.desktop.updates.downloading, { version: state.latestVersion })}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
@@ -84,10 +81,10 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "checking" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
{t(($) => $.desktop.updates.checking)}
|
||||
</>
|
||||
) : (
|
||||
"Check now"
|
||||
t(($) => $.desktop.updates.check_now)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
@@ -34,6 +36,15 @@ export function WorkspaceRouteLayout() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
// While a WindowOverlay is open (onboarding, accept-invite, new-workspace),
|
||||
// the underlying tab is still mounted in the React tree — so this layout
|
||||
// and its WelcomeAfterOnboarding Modal would render UNDER the overlay.
|
||||
// Because the modal uses a Portal that targets document.body, it ends up
|
||||
// rendered LATER in the DOM and visually outranks the overlay's z-50.
|
||||
// Suppress the modal whenever any overlay is active; the moment the
|
||||
// overlay closes the welcome hook re-evaluates and pops if its store
|
||||
// signal is still set.
|
||||
const overlayActive = useWindowOverlayStore((s) => s.overlay !== null);
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
|
||||
useEffect(() => {
|
||||
@@ -85,6 +96,14 @@ export function WorkspaceRouteLayout() {
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
{/* Reads the welcome-store transient signal parked by
|
||||
* OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay
|
||||
* (onboarding / accept-invite / new-workspace) is open so the modal
|
||||
* doesn't portal-jump in front of an active pre-workspace flow.
|
||||
* Once the overlay closes the hook re-evaluates and pops the
|
||||
* Modal — unless the store signal has already been consumed, in
|
||||
* which case the hook renders null. */}
|
||||
{!overlayActive && <WelcomeAfterOnboarding />}
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Activity } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { useTabScrollRestore } from "./use-tab-scroll-restore";
|
||||
|
||||
function Harness({ path }: { path: string }) {
|
||||
const ref = useTabScrollRestore(path);
|
||||
return (
|
||||
<div ref={ref} style={{ display: "contents" }}>
|
||||
<div
|
||||
data-tab-scroll-root
|
||||
data-testid="scroller"
|
||||
style={{ height: 100, overflow: "auto" }}
|
||||
>
|
||||
<div style={{ height: 1000 }} />
|
||||
</div>
|
||||
<div
|
||||
data-tab-scroll-root="aside"
|
||||
data-testid="aside"
|
||||
style={{ height: 100, overflow: "auto" }}
|
||||
>
|
||||
<div style={{ height: 1000 }} />
|
||||
</div>
|
||||
<div
|
||||
data-testid="unmarked"
|
||||
style={{ height: 100, overflow: "auto" }}
|
||||
>
|
||||
<div style={{ height: 1000 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App({ visible, path }: { visible: boolean; path: string }) {
|
||||
return (
|
||||
<Activity mode={visible ? "visible" : "hidden"}>
|
||||
<Harness path={path} />
|
||||
</Activity>
|
||||
);
|
||||
}
|
||||
|
||||
function setScroll(el: HTMLElement, top: number) {
|
||||
el.scrollTop = top;
|
||||
fireEvent.scroll(el);
|
||||
}
|
||||
|
||||
describe("useTabScrollRestore", () => {
|
||||
it("restores scroll position when a tab cycles through hidden -> visible", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const scroller = getByTestId("scroller") as HTMLElement;
|
||||
|
||||
setScroll(scroller, 500);
|
||||
expect(scroller.scrollTop).toBe(500);
|
||||
|
||||
// Simulate Activity hiding the subtree: layout would drop the offset.
|
||||
rerender(<App visible={false} path="/acme/issues/1" />);
|
||||
scroller.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={true} path="/acme/issues/1" />);
|
||||
expect(scroller.scrollTop).toBe(500);
|
||||
});
|
||||
|
||||
it("restores multiple named scroll roots independently", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const main = getByTestId("scroller") as HTMLElement;
|
||||
const aside = getByTestId("aside") as HTMLElement;
|
||||
|
||||
setScroll(main, 300);
|
||||
setScroll(aside, 150);
|
||||
|
||||
rerender(<App visible={false} path="/acme/issues/1" />);
|
||||
main.scrollTop = 0;
|
||||
aside.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={true} path="/acme/issues/1" />);
|
||||
expect(main.scrollTop).toBe(300);
|
||||
expect(aside.scrollTop).toBe(150);
|
||||
});
|
||||
|
||||
it("ignores scroll on elements without the data-tab-scroll-root marker", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const unmarked = getByTestId("unmarked") as HTMLElement;
|
||||
|
||||
setScroll(unmarked, 250);
|
||||
|
||||
rerender(<App visible={false} path="/acme/issues/1" />);
|
||||
unmarked.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={true} path="/acme/issues/1" />);
|
||||
expect(unmarked.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("drops saved offsets when the tab path changes (intra-tab navigation)", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const scroller = getByTestId("scroller") as HTMLElement;
|
||||
|
||||
setScroll(scroller, 500);
|
||||
|
||||
// Navigating within the tab swaps the active route — same marker key,
|
||||
// different page. We should NOT restore the prior page's offset.
|
||||
rerender(<App visible={true} path="/acme/issues/2" />);
|
||||
scroller.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={false} path="/acme/issues/2" />);
|
||||
rerender(<App visible={true} path="/acme/issues/2" />);
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Persist a tab's scroll positions across <Activity> visibility transitions.
|
||||
*
|
||||
* Tabs render under `<Activity mode="visible|hidden">`, which keeps React
|
||||
* state but loses DOM scrollTop — the subtree is taken out of layout while
|
||||
* hidden and rejoins with scrollTop=0. This hook records every marked
|
||||
* container's `scrollTop` while the tab is visible (continuously, via a
|
||||
* capture-phase scroll listener) and restores them in a `useLayoutEffect`
|
||||
* the next time the tab becomes visible, before the browser paints.
|
||||
*
|
||||
* Mark scroll containers in views with `data-tab-scroll-root`. The
|
||||
* attribute value is the cache key — defaults to `"main"` for unnamed
|
||||
* roots. Most pages have a single scroll container, so a bare attribute
|
||||
* is enough; named keys are only needed when a page has multiple
|
||||
* independently scrollable regions whose positions must all be restored.
|
||||
*
|
||||
* When the tab's path changes (intra-tab navigation), the saved offsets
|
||||
* are dropped — the new route's container shares the same marker key but
|
||||
* is a different page, and restoring the old offset would land the user
|
||||
* somewhere arbitrary on the new page.
|
||||
*/
|
||||
export function useTabScrollRestore(tabPath: string) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const savedRef = useRef<Map<string, number>>(new Map());
|
||||
const prevPathRef = useRef(tabPath);
|
||||
|
||||
if (prevPathRef.current !== tabPath) {
|
||||
savedRef.current.clear();
|
||||
prevPathRef.current = tabPath;
|
||||
}
|
||||
|
||||
// <Activity> cleans up effects on hidden and re-mounts them on visible,
|
||||
// so an empty-deps useLayoutEffect runs exactly on every hidden → visible
|
||||
// transition. Restoring here (before the browser paints) avoids any
|
||||
// flash at scrollTop=0.
|
||||
useLayoutEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const els = root.querySelectorAll<HTMLElement>("[data-tab-scroll-root]");
|
||||
els.forEach((el) => {
|
||||
const key = scrollKey(el);
|
||||
const saved = savedRef.current.get(key);
|
||||
if (saved !== undefined && el.scrollTop !== saved) {
|
||||
el.scrollTop = saved;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const onScroll = (e: Event) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (!target.hasAttribute("data-tab-scroll-root")) return;
|
||||
savedRef.current.set(scrollKey(target), target.scrollTop);
|
||||
};
|
||||
// Scroll events don't bubble, but capture catches them anyway.
|
||||
root.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
||||
return () => root.removeEventListener("scroll", onScroll, true);
|
||||
}, []);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
function scrollKey(el: HTMLElement): string {
|
||||
return el.getAttribute("data-tab-scroll-root") || "main";
|
||||
}
|
||||
@@ -5,17 +5,40 @@ import { useEffect } from "react";
|
||||
// Shared in-memory state that the mocked tab store reads / mutates. The test
|
||||
// records every method call so we can assert openInNewTab does NOT activate
|
||||
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
|
||||
type MockRouter = {
|
||||
state: { location: { pathname: string } };
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
pinned: boolean;
|
||||
router: MockRouter;
|
||||
};
|
||||
|
||||
function makeMockRouter(pathname: string): MockRouter {
|
||||
return {
|
||||
state: { location: { pathname } },
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<
|
||||
string,
|
||||
{ activeTabId: string; tabs: { id: string; path: string }[] }
|
||||
>,
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
|
||||
@@ -91,7 +114,14 @@ beforeEach(() => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, "desktopAPI", {
|
||||
@@ -170,6 +200,69 @@ describe("DesktopNavigationProvider.openInNewTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DesktopNavigationProvider.push with pinned active tab", () => {
|
||||
function pinActive(pathname: string) {
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: makeMockRouter(pathname),
|
||||
};
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/issues?filter=open");
|
||||
// Pathname unchanged → pinned interception declines and falls through to
|
||||
// the router's own navigate — openTab / setActiveTab must not fire.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/butter/inbox");
|
||||
// Cross-workspace push runs through tryRouteToOtherWorkspace before
|
||||
// tryRouteToPinnedNewTab, so switchWorkspace wins.
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.openInNewTab", () => {
|
||||
function renderTabProvider() {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
@@ -205,3 +298,58 @@ describe("TabNavigationProvider.openInNewTab", () => {
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push with pinned active tab", () => {
|
||||
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
|
||||
function renderPinnedTabProvider(pathname: string) {
|
||||
// The active tab and the per-tab router must share the same pathname:
|
||||
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
|
||||
// pathname (so query-only pushes routed via React Router still compare
|
||||
// correctly), while the TabNavigationProvider falls back to *its own*
|
||||
// router.navigate when no interception fires. In real desktop usage they
|
||||
// are the same router instance; this helper mirrors that invariant.
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname, search: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as ProviderRouter;
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: fakeRouter as unknown as MockRouter,
|
||||
};
|
||||
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return { getAdapter: () => adapter!, fakeRouter };
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
// Pinned interception short-circuits — the per-tab router must NOT
|
||||
// navigate, otherwise the pinned tab itself would move off its path.
|
||||
expect(fakeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/issues?filter=open");
|
||||
// Same pathname → pinned interception declines, push falls through to
|
||||
// the tab's own router.navigate, and no new tab is opened.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,37 @@ function tryRouteToOtherWorkspace(path: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes originating in a pinned tab and force them into a new
|
||||
* tab. Returns `true` if the navigation was redirected (caller should NOT
|
||||
* proceed). Pathname-only changes (search / hash / same-page state) are
|
||||
* allowed through so pinned filter / drawer / form-state interactions
|
||||
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
|
||||
* D2b (FINAL: same pathname → allowed in pinned tab).
|
||||
*
|
||||
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
|
||||
* if one exists, otherwise creates a new one. The newly-focused tab is
|
||||
* activated foreground — a pinned-tab push is an explicit user action, not
|
||||
* a background cmd+click, so the focus follows.
|
||||
*/
|
||||
function tryRouteToPinnedNewTab(path: string): boolean {
|
||||
const store = useTabStore.getState();
|
||||
const active = getActiveTab(store);
|
||||
if (!active?.pinned) return false;
|
||||
|
||||
// Use the live router pathname rather than `active.path` so query-only
|
||||
// navigations performed via React Router (which only sync pathname back
|
||||
// to the store) still compare correctly.
|
||||
const currentPathname = active.router.state.location.pathname;
|
||||
const newPathname = path.split("?")[0].split("#")[0];
|
||||
if (currentPathname === newPathname) return false;
|
||||
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, path, icon);
|
||||
if (newId) store.setActiveTab(newId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab
|
||||
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
|
||||
@@ -165,6 +196,7 @@ export function DesktopNavigationProvider({
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
active?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
@@ -240,6 +272,7 @@ export function TabNavigationProvider({
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
|
||||
@@ -25,12 +25,40 @@ import { AgentsPage } from "@multica/views/agents";
|
||||
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
|
||||
* from i18n. The route element has to be a component (not a literal JSX
|
||||
* value) for `useT` to run.
|
||||
*/
|
||||
function DesktopSettingsRoute() {
|
||||
const { t } = useT("settings");
|
||||
return (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: t(($) => $.desktop.tabs.updates),
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
* The tab system observes document.title via MutationObserver.
|
||||
@@ -173,24 +201,7 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: "Updates",
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
element: <DesktopSettingsRoute />,
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("../routes", () => ({
|
||||
import {
|
||||
sanitizeTabPath,
|
||||
migrateV1ToV2,
|
||||
migrateV2ToV3,
|
||||
useTabStore,
|
||||
} from "./tab-store";
|
||||
|
||||
@@ -277,3 +278,155 @@ describe("useTabStore actions", () => {
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePin", () => {
|
||||
it("flips a tab's pinned state", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a newly-pinned tab to the start of the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs[0].id).toBe(agentsId);
|
||||
expect(tabs[0].pinned).toBe(true);
|
||||
expect(tabs[1].pinned).toBe(false);
|
||||
expect(tabs[2].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("appends a second pinned tab after the first pinned tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
store.togglePin(projectsId);
|
||||
|
||||
// Both pinned, in the order they were pinned (agents first, projects
|
||||
// second), then the unpinned default tab.
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([
|
||||
agentsId,
|
||||
projectsId,
|
||||
tabs[2].id,
|
||||
]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("returns an unpinned tab to the start of the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
|
||||
// Pin both, then unpin one.
|
||||
store.togglePin(issuesId);
|
||||
store.togglePin(projectsId);
|
||||
store.togglePin(issuesId);
|
||||
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTab boundary clamp", () => {
|
||||
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag the pinned tab to index 2 (unpinned zone end).
|
||||
store.moveTab(0, 2);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag agents (index 2) to index 0 (pinned zone).
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// Clamped to index 1 — start of the unpinned zone.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs[1].id).toBe(agentsId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("reorders freely within the same zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
|
||||
// All unpinned; move agents (2) to position 0.
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.path)).toEqual([
|
||||
"/acme/agents",
|
||||
"/acme/issues",
|
||||
"/acme/projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateV2ToV3", () => {
|
||||
it("adds pinned=false to every persisted tab", () => {
|
||||
const v2 = {
|
||||
activeWorkspaceSlug: "acme",
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "t1",
|
||||
tabs: [
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const v3 = migrateV2ToV3(v2);
|
||||
expect(v3.activeWorkspaceSlug).toBe("acme");
|
||||
expect(v3.byWorkspace.acme.tabs).toEqual([
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles missing byWorkspace gracefully", () => {
|
||||
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
|
||||
expect(v3.byWorkspace).toEqual({});
|
||||
expect(v3.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,14 @@ export interface Tab {
|
||||
router: DataRouter;
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
/**
|
||||
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
|
||||
* X close button, and turn any `navigation.push()` originating in them into
|
||||
* an `openInNewTab()` so they stay parked on their original path. Pinning
|
||||
* is invariant-preserving: pinned tabs always come before unpinned tabs in
|
||||
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
|
||||
*/
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabGroup {
|
||||
@@ -78,8 +86,20 @@ interface TabStore {
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Patch history tracking of a tab. Finds across groups. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Reorder within the active workspace's group only. */
|
||||
/**
|
||||
* Reorder within the active workspace's group only. Clamped so a tab can
|
||||
* never cross the pinned / unpinned boundary — a drag that would move a
|
||||
* pinned tab into the unpinned zone (or vice versa) is dropped at the
|
||||
* boundary instead. This keeps the "pinned tabs first" invariant without
|
||||
* requiring callers to know about it.
|
||||
*/
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
|
||||
* zone; unpinning moves it to the start of the unpinned zone. Both
|
||||
* preserve the "pinned tabs before unpinned tabs" invariant.
|
||||
*/
|
||||
togglePin: (tabId: string) => void;
|
||||
/**
|
||||
* After the workspace list arrives/changes (login, realtime delete), drop
|
||||
* any tab group whose slug is no longer in `validSlugs`, and repoint
|
||||
@@ -190,9 +210,17 @@ function makeTab(path: string, title: string, icon: string): Tab {
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the first unpinned tab in a group (== pinned count). */
|
||||
function pinnedBoundary(tabs: Tab[]): number {
|
||||
let i = 0;
|
||||
while (i < tabs.length && tabs[i].pinned) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Default entry point for a workspace — its issues list. */
|
||||
function defaultPathFor(slug: string): string {
|
||||
return `/${slug}/issues`;
|
||||
@@ -453,17 +481,63 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
|
||||
|
||||
// Clamp the drop position to within the source tab's group (pinned vs
|
||||
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
|
||||
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
|
||||
const boundary = pinnedBoundary(group.tabs);
|
||||
const source = group.tabs[fromIndex];
|
||||
let clampedTo: number;
|
||||
if (source.pinned) {
|
||||
// boundary is exclusive upper bound for pinned-zone indices.
|
||||
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
|
||||
} else {
|
||||
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
|
||||
}
|
||||
if (clampedTo === fromIndex) return;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
...group,
|
||||
tabs: arrayMove(group.tabs, fromIndex, toIndex),
|
||||
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
togglePin(tabId) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const nextTab: Tab = { ...current, pinned: !current.pinned };
|
||||
|
||||
// Remove from current position, then insert at the new zone boundary:
|
||||
// pinning → end of pinned zone (just before first unpinned tab)
|
||||
// unpinning → start of unpinned zone (right after last pinned tab)
|
||||
const withoutCurrent = [
|
||||
...group.tabs.slice(0, index),
|
||||
...group.tabs.slice(index + 1),
|
||||
];
|
||||
const newBoundary = pinnedBoundary(withoutCurrent);
|
||||
const insertAt = newBoundary;
|
||||
const nextTabs = [
|
||||
...withoutCurrent.slice(0, insertAt),
|
||||
nextTab,
|
||||
...withoutCurrent.slice(insertAt),
|
||||
];
|
||||
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
let changed = false;
|
||||
@@ -497,17 +571,23 @@ export const useTabStore = create<TabStore>()(
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 2,
|
||||
version: 3,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 → v2: flat `tabs` array → per-workspace grouping.
|
||||
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
|
||||
// are dropped — they have no workspace to belong to, and the new
|
||||
// model's invariant is "every tab lives in a workspace group".
|
||||
if (version < 2 && persistedState && typeof persistedState === "object") {
|
||||
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
|
||||
let state = persistedState;
|
||||
if (version < 2 && state && typeof state === "object") {
|
||||
state = migrateV1ToV2(state as Partial<V1Persisted>);
|
||||
}
|
||||
return persistedState as V2Persisted;
|
||||
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
|
||||
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
|
||||
if (version < 3 && state && typeof state === "object") {
|
||||
state = migrateV2ToV3(state as V2Persisted);
|
||||
}
|
||||
return state as V3Persisted;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
activeWorkspaceSlug: state.activeWorkspaceSlug,
|
||||
@@ -517,15 +597,19 @@ export const useTabStore = create<TabStore>()(
|
||||
{
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map(
|
||||
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
|
||||
rest,
|
||||
({
|
||||
router: _router,
|
||||
historyIndex: _hi,
|
||||
historyLength: _hl,
|
||||
...rest
|
||||
}) => rest,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<V2Persisted> | undefined;
|
||||
const persisted = persistedState as Partial<V3Persisted> | undefined;
|
||||
if (!persisted?.byWorkspace) return currentState;
|
||||
|
||||
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
@@ -552,9 +636,14 @@ export const useTabStore = create<TabStore>()(
|
||||
router: createTabRouter(clean),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: pTab.pinned === true,
|
||||
});
|
||||
}
|
||||
if (tabs.length === 0) continue;
|
||||
// Enforce the "pinned first" invariant on rehydration in case a
|
||||
// user (or a buggy older write) persisted the pinned tabs out of
|
||||
// order. Stable sort preserves intra-group order.
|
||||
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
|
||||
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
|
||||
? pGroup.activeTabId
|
||||
: tabs[0].id;
|
||||
@@ -605,6 +694,38 @@ interface V2Persisted {
|
||||
byWorkspace: Record<string, V2PersistedGroup>;
|
||||
}
|
||||
|
||||
interface V3PersistedTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
interface V3PersistedGroup {
|
||||
tabs: V3PersistedTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V3Persisted {
|
||||
activeWorkspaceSlug: string | null;
|
||||
byWorkspace: Record<string, V3PersistedGroup>;
|
||||
}
|
||||
|
||||
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
|
||||
const byWorkspace: Record<string, V3PersistedGroup> = {};
|
||||
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
|
||||
byWorkspace[slug] = {
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
|
||||
byWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
|
||||
const byWorkspace: Record<string, V2PersistedGroup> = {};
|
||||
const oldTabs = v1.tabs ?? [];
|
||||
|
||||
27
apps/desktop/src/shared/navigation-gestures.test.ts
Normal file
27
apps/desktop/src/shared/navigation-gestures.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
navigationGestureFromSwipe,
|
||||
} from "./navigation-gestures";
|
||||
|
||||
describe("navigationGestureFromSwipe", () => {
|
||||
it("maps horizontal macOS swipe directions to browser-style history", () => {
|
||||
expect(navigationGestureFromSwipe("right")).toBe("back");
|
||||
expect(navigationGestureFromSwipe("left")).toBe("forward");
|
||||
});
|
||||
|
||||
it("ignores vertical and unknown directions", () => {
|
||||
expect(navigationGestureFromSwipe("up")).toBeNull();
|
||||
expect(navigationGestureFromSwipe("down")).toBeNull();
|
||||
expect(navigationGestureFromSwipe("sideways")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNavigationGesture", () => {
|
||||
it("accepts only the renderer navigation gestures", () => {
|
||||
expect(isNavigationGesture("back")).toBe(true);
|
||||
expect(isNavigationGesture("forward")).toBe(true);
|
||||
expect(isNavigationGesture("right")).toBe(false);
|
||||
expect(isNavigationGesture(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
15
apps/desktop/src/shared/navigation-gestures.ts
Normal file
15
apps/desktop/src/shared/navigation-gestures.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const NAVIGATION_GESTURE_CHANNEL = "navigation:gesture";
|
||||
|
||||
export type NavigationGesture = "back" | "forward";
|
||||
|
||||
export function isNavigationGesture(value: unknown): value is NavigationGesture {
|
||||
return value === "back" || value === "forward";
|
||||
}
|
||||
|
||||
export function navigationGestureFromSwipe(
|
||||
direction: string,
|
||||
): NavigationGesture | null {
|
||||
if (direction === "right") return "back";
|
||||
if (direction === "left") return "forward";
|
||||
return null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
|
||||
## What an agent can do
|
||||
|
||||
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
|
||||
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
|
||||
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
|
||||
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
|
||||
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
|
||||
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
|
||||
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
|
||||
|
||||
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 智能体(agent)是 Multica 工作区里的一等公民成员
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
|
||||
## 智能体能做什么
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
- **[被分配 issue](/assigning-issues)** —— 作为 assignee,分配后它会自动开工
|
||||
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
|
||||
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
|
||||
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
|
||||
@@ -72,7 +72,7 @@ multica daemon status
|
||||
|
||||
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
|
||||
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
|
||||
|
||||
## 5. Create an agent
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"mobile-app",
|
||||
"---Developers---",
|
||||
"developers"
|
||||
]
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"mobile-app",
|
||||
"---开发者---",
|
||||
"developers"
|
||||
]
|
||||
|
||||
82
apps/docs/content/docs/mobile-app.mdx
Normal file
82
apps/docs/content/docs/mobile-app.mdx
Normal 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 10–20 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 10–20 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 <app>" / "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 10–20 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`.
|
||||
82
apps/docs/content/docs/mobile-app.zh.mdx
Normal file
82
apps/docs/content/docs/mobile-app.zh.mdx
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: 移动 App(iOS)
|
||||
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 约 10–20 分钟,之后每次约 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 —— 大约 10–20 分钟。之后 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 抢注就行。
|
||||
|
||||
**"无法启动 <app>" / "未受信任的开发者"** —— 要么过了 7 天有效期(重跑 build),要么需要在 iPhone 上手动信任开发者证书:设置 → 通用 → VPN 与设备管理 → 点你的 Apple ID → 信任。
|
||||
|
||||
**Build 卡在 `Pod install` 或者编译很久不动** —— 首次 build 就是 10–20 分钟,CocoaPods 要下载依赖、Xcode 要从源码编译 React Native。后续会快很多。
|
||||
|
||||
**App 连不上后端** —— 确认 `apps/mobile/.env.production` 没动过(默认值 `EXPO_PUBLIC_API_URL=https://api.multica.ai`)。如果你改过,用 `git checkout apps/mobile/.env.production` 还原。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Self-host quickstart
|
||||
description: Run Multica on your own server or machine with Docker. Takes about 10 minutes.
|
||||
description: Run Multica on your own server or machine with Docker (or Helm on Kubernetes). Takes about 10 minutes.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -18,6 +18,10 @@ Agent **execution** still relies on the [daemon](/daemon-runtimes) you run local
|
||||
|
||||
## 1. Pull the project and start the backend
|
||||
|
||||
<Callout type="info">
|
||||
**Already on Kubernetes?** Skip Docker and use the Helm chart instead — jump to [Kubernetes deployment](#kubernetes-deployment-alternative) below, then come back to [Step 4](#4-first-login--create-a-workspace) for first login.
|
||||
</Callout>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
@@ -155,6 +159,53 @@ After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` i
|
||||
|
||||
Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-create-an-agent).
|
||||
|
||||
## Kubernetes deployment (alternative)
|
||||
|
||||
If you already run a Kubernetes cluster, the repo also ships a Helm chart at `deploy/helm/multica/`. It's the equivalent of `make selfhost` for k8s — same backend image, frontend image, and `pgvector/pgvector:pg17` Postgres, packaged as Deployments / Services / Ingresses with one `ConfigMap` rendered from `values.yaml`. Authored against k3s + Traefik + `local-path` and should work on any cluster with an Ingress controller and a default `ReadWriteOnce` StorageClass.
|
||||
|
||||
The chart **does not template secret values**. It references a Secret named `multica-secrets` by name, so real JWT / DB / Resend / Google keys never need to live in git or in `values.yaml`. Create the namespace + Secret once with kubectl:
|
||||
|
||||
```bash
|
||||
kubectl create namespace multica
|
||||
|
||||
kubectl -n multica create secret generic multica-secrets \
|
||||
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
|
||||
--from-literal=RESEND_API_KEY="" \
|
||||
--from-literal=GOOGLE_CLIENT_SECRET="" \
|
||||
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
|
||||
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
|
||||
```
|
||||
|
||||
Then install the chart:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
```
|
||||
|
||||
Defaults assume the hostnames `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Add them to `/etc/hosts` (or local DNS) pointing at any node IP where your Ingress is reachable. To use different hostnames, copy `deploy/helm/multica/values.yaml`, edit `ingress.frontend.host` / `ingress.backend.host` and the matching `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri`, then install with `-f my-values.yaml`.
|
||||
|
||||
On a cold cluster the backend can stay `Running` but not `Ready` for a few minutes while it waits on Postgres and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once it's `Ready`:
|
||||
|
||||
```bash
|
||||
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
|
||||
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
```
|
||||
|
||||
Then open `http://multica.dev.lan` and continue at [Step 4 — First login](#4-first-login--create-a-workspace) above. Point the CLI at your Ingress hostnames:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url http://api.multica.dev.lan \
|
||||
--app-url http://multica.dev.lan
|
||||
```
|
||||
|
||||
To pull the latest images without changing the chart, `kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`. To pin a specific Multica release, set `images.backend.tag` / `images.frontend.tag` in your values file and `helm upgrade`. `helm -n multica uninstall multica` removes the workloads but keeps the PVCs and Secret; `kubectl delete namespace multica` wipes everything.
|
||||
|
||||
The full reference — three login modes, the `backend` ExternalName workaround for the build-time-baked `REMOTE_API_URL` in the web image, resource limits, and TLS — lives in the repo's [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative).
|
||||
|
||||
## Common issues
|
||||
|
||||
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Self-Host 快速上手
|
||||
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来。约 10 分钟。
|
||||
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来(也可以在 Kubernetes 上用 Helm)。约 10 分钟。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -18,6 +18,10 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 1. 拉取项目 + 一键启动后端
|
||||
|
||||
<Callout type="info">
|
||||
**已经有 Kubernetes 集群?** 不用走 Docker,直接用 Helm chart——跳到下面的 [Kubernetes 部署(替代方案)](#kubernetes-部署替代方案),装完再回到 [第 4 步](#4-首次登录--创建工作区) 完成登录。
|
||||
</Callout>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
@@ -154,6 +158,53 @@ multica.example.com {
|
||||
|
||||
流程和 Cloud 一样——见 [Cloud 快速上手 → 5-6 步](/cloud-quickstart#5-创建智能体)。
|
||||
|
||||
## Kubernetes 部署(替代方案)
|
||||
|
||||
如果你已经在跑 Kubernetes 集群,仓库里也带了一个 Helm chart,路径 `deploy/helm/multica/`。它就是 k8s 版的 `make selfhost`——一样的 backend 镜像、frontend 镜像、`pgvector/pgvector:pg17` Postgres,封装成 Deployment / Service / Ingress,再加上一个由 `values.yaml` 渲染出来的 `ConfigMap`。这套 chart 是按照 k3s + Traefik + `local-path` 写的,集群里只要有 Ingress controller 和默认的 `ReadWriteOnce` StorageClass 就能跑,其他类型的集群稍微改一改也能用。
|
||||
|
||||
这个 chart **不会模板化任何敏感值**。它通过 name 引用一个叫 `multica-secrets` 的 Secret,所以真实的 JWT / DB / Resend / Google 密钥永远不用进 git,也不用进 `values.yaml`。先用 kubectl 一次性把命名空间和 Secret 建好:
|
||||
|
||||
```bash
|
||||
kubectl create namespace multica
|
||||
|
||||
kubectl -n multica create secret generic multica-secrets \
|
||||
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
|
||||
--from-literal=RESEND_API_KEY="" \
|
||||
--from-literal=GOOGLE_CLIENT_SECRET="" \
|
||||
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
|
||||
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
|
||||
```
|
||||
|
||||
再装 chart:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
```
|
||||
|
||||
默认主机名是 `multica.dev.lan`(web)和 `api.multica.dev.lan`(backend)。把它们加进 `/etc/hosts`(或者本地 DNS),指向任意一个 Ingress 可达的节点 IP 就行。要换主机名,就把 `deploy/helm/multica/values.yaml` 复制一份,改掉 `ingress.frontend.host` / `ingress.backend.host`,再把 `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri` 改成相应的地址,然后 `helm install ... -f my-values.yaml`。
|
||||
|
||||
冷集群上 backend 可能会 `Running` 但 `Not Ready` 持续几分钟,等 Postgres 起来并跑完 migration——startupProbe 会兜住这一段,pod 不会被 liveness 重启。等它 `Ready` 之后:
|
||||
|
||||
```bash
|
||||
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
|
||||
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
```
|
||||
|
||||
然后浏览器打开 `http://multica.dev.lan`,回到上面的 [第 4 步——首次登录](#4-首次登录--创建工作区) 继续。命令行连到你的 Ingress 主机:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url http://api.multica.dev.lan \
|
||||
--app-url http://multica.dev.lan
|
||||
```
|
||||
|
||||
只想拉最新镜像、不动 chart:`kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`。要锁到某个 Multica 版本,就在 values 文件里设 `images.backend.tag` / `images.frontend.tag`,再 `helm upgrade`。`helm -n multica uninstall multica` 只删工作负载,PVC 和 Secret 都保留;`kubectl delete namespace multica` 才会全清。
|
||||
|
||||
完整参考——三种登录方式、为了绕过 web 镜像 build-time 写死的 `REMOTE_API_URL` 而加的 `backend` ExternalName 别名、资源限制、TLS——都在仓库的 [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
|
||||
|
||||
@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
|
||||
|
||||
Behavior:
|
||||
|
||||
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
|
||||
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
|
||||
- By default, targets the issue's **current agent assignee** — useful when you want the rerun to follow the current assignment regardless of who ran the prior task.
|
||||
- The execution-log retry button on a specific row sends that row's task ID alongside, so the rerun targets **the agent that ran that exact task** — not the current assignee. This makes per-row retry meaningful for squad workers, parallel @-mention agents, or rows whose agent has since been displaced by a reassignment.
|
||||
- **Cancels** the target agent's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
|
||||
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
|
||||
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
|
||||
|
||||
@@ -89,7 +90,7 @@ Comparison:
|
||||
| Trigger | System, based on failure reason | You, manually |
|
||||
| Ceiling | 2 attempts | No limit |
|
||||
| Applicable sources | Issues, chat | Issues with an agent assignee |
|
||||
| Agent picked | Same agent as the failed task | Issue's current assignee |
|
||||
| Agent picked | Same agent as the failed task | Source task's agent (UI per-row retry) or issue's current assignee (CLI / no task_id) |
|
||||
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
|
||||
|
||||
## How a failed task affects issue status
|
||||
|
||||
@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
|
||||
|
||||
行为:
|
||||
|
||||
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了,rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
|
||||
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景。
|
||||
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出,rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作。
|
||||
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试。
|
||||
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行,再继续之前的对话只会重放被污染的上下文。(自动重试则相反,会继承会话——那条路径处理的是基础设施层面的失败,不是产出不好。)
|
||||
|
||||
@@ -89,7 +90,7 @@ multica issue rerun <issue-id>
|
||||
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
|
||||
| 上限 | 2 次 | 无上限 |
|
||||
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry:那一行任务的 agent;CLI / 不带 task_id:issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
34
apps/mobile/.env.example
Normal file
34
apps/mobile/.env.example
Normal 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
|
||||
5
apps/mobile/.env.production
Normal file
5
apps/mobile/.env.production
Normal 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
10
apps/mobile/.env.staging
Normal 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
28
apps/mobile/.gitignore
vendored
Normal 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
575
apps/mobile/CLAUDE.md
Normal 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
|
||||
5–7 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
104
apps/mobile/README.md
Normal 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 10–20 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
79
apps/mobile/app.config.ts
Normal 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 },
|
||||
};
|
||||
};
|
||||
149
apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx
Normal file
149
apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
428
apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx
Normal file
428
apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx
Normal file
199
apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/mobile/app/(app)/[workspace]/(tabs)/more.tsx
Normal file
16
apps/mobile/app/(app)/[workspace]/(tabs)/more.tsx
Normal 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"} />;
|
||||
}
|
||||
373
apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx
Normal file
373
apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx
Normal 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.";
|
||||
}
|
||||
}
|
||||
|
||||
339
apps/mobile/app/(app)/[workspace]/_layout.tsx
Normal file
339
apps/mobile/app/(app)/[workspace]/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
apps/mobile/app/(app)/[workspace]/chat-sessions.tsx
Normal file
121
apps/mobile/app/(app)/[workspace]/chat-sessions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
apps/mobile/app/(app)/[workspace]/issue/[id].tsx
Normal file
222
apps/mobile/app/(app)/[workspace]/issue/[id].tsx
Normal 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 },
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
203
apps/mobile/app/(app)/[workspace]/issue/[id]/edit.tsx
Normal file
203
apps/mobile/app/(app)/[workspace]/issue/[id]/edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
110
apps/mobile/app/(app)/[workspace]/issue/[id]/runs.tsx
Normal file
110
apps/mobile/app/(app)/[workspace]/issue/[id]/runs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
apps/mobile/app/(app)/[workspace]/issues-filter.tsx
Normal file
173
apps/mobile/app/(app)/[workspace]/issues-filter.tsx
Normal 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>;
|
||||
}
|
||||
32
apps/mobile/app/(app)/[workspace]/mention-picker.tsx
Normal file
32
apps/mobile/app/(app)/[workspace]/mention-picker.tsx
Normal 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} />;
|
||||
}
|
||||
12
apps/mobile/app/(app)/[workspace]/more/agents.tsx
Normal file
12
apps/mobile/app/(app)/[workspace]/more/agents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
383
apps/mobile/app/(app)/[workspace]/more/issues.tsx
Normal file
383
apps/mobile/app/(app)/[workspace]/more/issues.tsx
Normal 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.";
|
||||
}
|
||||
}
|
||||
235
apps/mobile/app/(app)/[workspace]/more/pins.tsx
Normal file
235
apps/mobile/app/(app)/[workspace]/more/pins.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/mobile/app/(app)/[workspace]/more/projects.tsx
Normal file
126
apps/mobile/app/(app)/[workspace]/more/projects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
apps/mobile/app/(app)/[workspace]/more/settings.tsx
Normal file
279
apps/mobile/app/(app)/[workspace]/more/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
226
apps/mobile/app/(app)/[workspace]/more/settings/profile.tsx
Normal file
226
apps/mobile/app/(app)/[workspace]/more/settings/profile.tsx
Normal 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't be changed here.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button onPress={handleSave} disabled={!dirty || saving}>
|
||||
<Text>{saving ? "Saving…" : "Save"}</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
145
apps/mobile/app/(app)/[workspace]/new-issue.tsx
Normal file
145
apps/mobile/app/(app)/[workspace]/new-issue.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
238
apps/mobile/app/(app)/[workspace]/project/[id].tsx
Normal file
238
apps/mobile/app/(app)/[workspace]/project/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
201
apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx
Normal file
201
apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
270
apps/mobile/app/(app)/[workspace]/project/new.tsx
Normal file
270
apps/mobile/app/(app)/[workspace]/project/new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
502
apps/mobile/app/(app)/[workspace]/search.tsx
Normal file
502
apps/mobile/app/(app)/[workspace]/search.tsx
Normal 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 “{trimmedQuery}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
142
apps/mobile/app/(app)/[workspace]/switch-workspace.tsx
Normal file
142
apps/mobile/app/(app)/[workspace]/switch-workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/mobile/app/(app)/_layout.tsx
Normal file
15
apps/mobile/app/(app)/_layout.tsx
Normal 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 }} />;
|
||||
}
|
||||
89
apps/mobile/app/(app)/select-workspace.tsx
Normal file
89
apps/mobile/app/(app)/select-workspace.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
5
apps/mobile/app/(auth)/_layout.tsx
Normal file
5
apps/mobile/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
85
apps/mobile/app/(auth)/login.tsx
Normal file
85
apps/mobile/app/(auth)/login.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
146
apps/mobile/app/(auth)/verify.tsx
Normal file
146
apps/mobile/app/(auth)/verify.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user