mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
81 Commits
feat/agent
...
agent/j/df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba3a5ad6e | ||
|
|
2d44ad36ce | ||
|
|
38bcc91b64 | ||
|
|
91506e7f7b | ||
|
|
a67bf81225 | ||
|
|
382e294e8c | ||
|
|
65ed5744a6 | ||
|
|
77c7267105 | ||
|
|
88fe6d754f | ||
|
|
e6324aad6c | ||
|
|
bf8a346cf0 | ||
|
|
d8075a5775 | ||
|
|
6bc3d14eb3 | ||
|
|
612ac8f28e | ||
|
|
40da88fc16 | ||
|
|
960befa56f | ||
|
|
312ee29cb6 | ||
|
|
ec1589f7b6 | ||
|
|
e33b893c3f | ||
|
|
c5b4c45e41 | ||
|
|
e351f89843 | ||
|
|
bf1e375015 | ||
|
|
636fa1adb4 | ||
|
|
441fa18db4 | ||
|
|
13f74e651a | ||
|
|
5877cff9de | ||
|
|
2b3e408db1 | ||
|
|
f9dfb3b9fc | ||
|
|
ce98b1c9ef | ||
|
|
b9bf2653be | ||
|
|
5c1fad4508 | ||
|
|
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 |
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=
|
||||
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.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
|
||||
|
||||
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
|
||||
|
||||
56
CLAUDE.md
56
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)
|
||||
@@ -179,21 +203,29 @@ Every Go handler in `server/internal/handler/` follows these rules. The conventi
|
||||
|
||||
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
|
||||
|
||||
### Dependency Declaration Rule
|
||||
|
||||
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
|
||||
|
||||
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
|
||||
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
|
||||
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
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 +233,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 +244,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.
|
||||
|
||||
@@ -8,6 +8,8 @@ WORKDIR /app
|
||||
# Copy workspace config and all package.json files for dependency resolution
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
# postinstall runs fumadocs-mdx which reads apps/web/source.config.ts
|
||||
COPY apps/web/source.config.ts apps/web/source.config.ts
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/views/package.json packages/views/
|
||||
|
||||
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:
|
||||
|
||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
dedupe: ["react", "react-dom", "@tanstack/react-query"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,15 +45,21 @@
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"electron-updater": "^6.8.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@multica/eslint-config": "workspace:*",
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
@@ -65,9 +71,8 @@
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
106
apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.ts
Normal file
106
apps/desktop/src/renderer/src/hooks/use-tab-scroll-restore.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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.
|
||||
*
|
||||
* For virtualized children (Virtuoso, react-virtual, etc.) the single
|
||||
* synchronous `scrollTop = saved` inside useLayoutEffect isn't enough:
|
||||
* the child registers its observers in a passive useEffect that fires
|
||||
* later, so at restore time the container's scrollHeight has collapsed
|
||||
* to clientHeight and the browser clamps our assignment to 0. The
|
||||
* restore loops across rAF frames until the assignment sticks, which
|
||||
* lets virtualization rehydrate before we give up.
|
||||
*/
|
||||
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) handles the
|
||||
// common case without a flash.
|
||||
//
|
||||
// The synchronous set isn't enough for virtualized lists though
|
||||
// (issue-detail uses Virtuoso with customScrollParent). Virtuoso wires
|
||||
// its scroll/resize observers in a passive useEffect, which fires AFTER
|
||||
// useLayoutEffect — so at the moment we try to restore, the spacer that
|
||||
// gives the container its tall scrollHeight hasn't been re-established
|
||||
// yet. The browser silently clamps `scrollTop = saved` down to 0 because
|
||||
// `scrollHeight === clientHeight` in that window. Retry across rAF
|
||||
// frames until the set sticks (or we time out around the time any sane
|
||||
// child should have laid out, ~500ms).
|
||||
useLayoutEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const els = root.querySelectorAll<HTMLElement>("[data-tab-scroll-root]");
|
||||
const cancellers: Array<() => void> = [];
|
||||
els.forEach((el) => {
|
||||
const key = scrollKey(el);
|
||||
const saved = savedRef.current.get(key);
|
||||
if (saved === undefined) return;
|
||||
el.scrollTop = saved;
|
||||
if (el.scrollTop === saved) return;
|
||||
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30; // ~500ms at 60fps
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
el.scrollTop = saved;
|
||||
attempts++;
|
||||
if (el.scrollTop === saved) return;
|
||||
if (attempts >= maxAttempts) return;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
cancellers.push(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
});
|
||||
return () => cancellers.forEach((c) => c());
|
||||
}, []);
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useEffect } from "react";
|
||||
// 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 } };
|
||||
state: { location: { pathname: string; search: string; hash: string } };
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
@@ -17,9 +17,9 @@ type MockTab = {
|
||||
router: MockRouter;
|
||||
};
|
||||
|
||||
function makeMockRouter(pathname: string): MockRouter {
|
||||
function makeMockRouter(pathname: string, search = "", hash = ""): MockRouter {
|
||||
return {
|
||||
state: { location: { pathname } },
|
||||
state: { location: { pathname, search, hash } },
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -263,6 +263,32 @@ describe("DesktopNavigationProvider.push with pinned active tab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DesktopNavigationProvider.push duplicate path guard", () => {
|
||||
it("does not navigate when the target exactly matches the active tab location", () => {
|
||||
const activeRouter = makeMockRouter("/acme/issues/child");
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: "/acme/issues/child",
|
||||
pinned: false,
|
||||
router: activeRouter,
|
||||
};
|
||||
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
|
||||
adapter!.push("/acme/issues/child");
|
||||
|
||||
expect(activeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.openInNewTab", () => {
|
||||
function renderTabProvider() {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
@@ -299,6 +325,46 @@ describe("TabNavigationProvider.openInNewTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push duplicate path guard", () => {
|
||||
function renderTabProviderAt(
|
||||
pathname: string,
|
||||
search = "",
|
||||
hash = "",
|
||||
) {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname, search, hash } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return { getAdapter: () => adapter!, fakeRouter };
|
||||
}
|
||||
|
||||
it("does not navigate when the target exactly matches the current full location", () => {
|
||||
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues/child");
|
||||
|
||||
getAdapter().push("/acme/issues/child");
|
||||
|
||||
expect(fakeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still navigates when only search or hash differs", () => {
|
||||
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues");
|
||||
|
||||
getAdapter().push("/acme/issues?filter=open#top");
|
||||
|
||||
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open#top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push with pinned active tab", () => {
|
||||
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
|
||||
@@ -310,7 +376,7 @@ describe("TabNavigationProvider.push with pinned active tab", () => {
|
||||
// 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: "" } },
|
||||
state: { location: { pathname, search: "", hash: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as ProviderRouter;
|
||||
|
||||
@@ -89,6 +89,11 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function routerLocationPath(router: DataRouter): string {
|
||||
const { pathname, search, hash } = router.state.location;
|
||||
return `${pathname}${search ?? ""}${hash ?? ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes that change workspace. Returns `true` if the navigation
|
||||
* was delegated to the tab store (caller should NOT proceed).
|
||||
@@ -195,6 +200,7 @@ export function DesktopNavigationProvider({
|
||||
}
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (active && routerLocationPath(active.router) === path) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
active?.router.navigate(path);
|
||||
@@ -271,6 +277,7 @@ export function TabNavigationProvider({
|
||||
() => ({
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (routerLocationPath(router) === path) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
router.navigate(path);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -64,9 +64,9 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
|
||||
|
||||
<Callout type="warning">
|
||||
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
|
||||
**Values in `custom_env` are stored in plaintext in Multica's server database.** Agent list/get responses no longer carry env values at all — only an opaque count. Reading values requires a workspace owner or admin to hit the dedicated, audited `GET /api/agents/{id}/env` endpoint (CLI: `multica agent env get <id>`). Agents running tasks can NOT use their host's owner credentials to reveal env on other agents — the endpoint denies agent-actor sessions.
|
||||
|
||||
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
|
||||
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly. Database backups and DB audits remain a meaningful exposure surface.
|
||||
</Callout>
|
||||
|
||||
## Custom CLI arguments (custom_args)
|
||||
@@ -96,7 +96,7 @@ Arguments are passed as-is, not through a shell (no injection risk), but whether
|
||||
|
||||
New agents default to `private`.
|
||||
|
||||
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
|
||||
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't read sensitive config (env values never appear in agent list/get responses; MCP config is masked for non-owners). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
|
||||
|
||||
## Concurrency limit
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn,不会报错)。
|
||||
|
||||
<Callout type="warning">
|
||||
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值(API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
|
||||
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** agent list/get 接口不再返回任何 env 值——只返回键的数量。读取真实值需要 workspace owner / admin 调用专用且会审计的 `GET /api/agents/{id}/env`(CLI: `multica agent env get <id>`)。正在执行任务的智能体即便宿主是 owner,也无法借此读取其它智能体的 env——这个接口拒绝 agent-actor 会话。数据库备份、DB 审计里仍然能看到真实值。
|
||||
|
||||
**不要把高价值 secret 放进 `custom_env`**(生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT),定期轮换。
|
||||
</Callout>
|
||||
@@ -96,7 +96,7 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
|
||||
|
||||
新建默认 `private`。
|
||||
|
||||
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
|
||||
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置(env 值从不会出现在 agent list/get 响应里;MCP 配置对非 owner 会被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
|
||||
|
||||
## 并发上限
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`). For Claude Code and Codex, host-machine skills under `~/.claude/skills/` and `~/.codex/skills/` are **merged by default** for each agent (so existing personal workflows keep working) — toggle the per-agent **Allow host-machine Skills** switch off to isolate a shared agent against a broken local skill on one operator's machine (GitHub #3052). Other runtimes ignore this setting — the daemon does not actively manage user-level skill discovery for them today.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
@@ -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` 有问题
|
||||
|
||||
@@ -16,6 +16,24 @@ Multica supports two skill sources:
|
||||
|
||||
Most of the time you want **workspace skills**: import once, every teammate's agent can use it. Local skills are a fit when you want to test locally first, or when the content involves sensitive local material.
|
||||
|
||||
### Per-agent host-skill merge toggle
|
||||
|
||||
By default Multica **merges** the host machine's user-global skill directory into the agent (so existing personal workflows that rely on locally installed Claude skills keep working out of the box). You can flip the toggle to **ignore** when a shared agent should be hardened against a broken local skill on one operator's machine — that scenario crashes the Claude CLI before it ever reads a prompt (silent `broken pipe` exits, see GitHub #3052).
|
||||
|
||||
Each agent carries a `skills_local` switch you can change from the **Create Agent** dialog or the agent's **Skills** tab:
|
||||
|
||||
- **On — Merge (default)**: the runtime inherits the full `~/.claude/` directory, including its `skills/`. Matches the pre-MUL-2603 behavior — recommended for personal agents that intentionally rely on locally installed skills.
|
||||
- **Off — Ignore**: the runtime launches against a per-task config directory that mirrors the host's effective Claude config dir *except* for `skills/`. The source is resolved with precedence agent `custom_env` `CLAUDE_CONFIG_DIR` > parent process `CLAUDE_CONFIG_DIR` > `~/.claude/`, so an operator who pinned Claude at a managed install keeps the right credentials and settings. Login credentials (`.credentials.json`), global settings, plugins, and agents pass through, so Claude Code authentication keeps working — only the user-global skills directory is hidden from the CLI. On Windows hosts without Developer Mode the mirror falls back to directory junctions and hardlink/copy for files, so the passthrough still works without elevated privileges. Recommended for any shared agent where host-skill safety matters.
|
||||
|
||||
Workspace skills under `{workDir}/.claude/skills/` (and the equivalent workdir-scoped paths for other runtimes) are loaded regardless of the toggle — this switch only governs the host's user-global skills directory.
|
||||
|
||||
**Which runtimes the toggle covers today:** Claude Code and Codex are the two runtimes where the daemon actively manages user-skill discovery, so they are the two that honor the toggle:
|
||||
|
||||
- **Claude Code** — when set to **Off**, `CLAUDE_CONFIG_DIR` points at a per-task scratch dir that mirrors the host's effective Claude config dir *except* for `skills/` (see above).
|
||||
- **Codex** — Codex always runs inside a per-task `CODEX_HOME`. When set to **On (default)** the daemon seeds that `CODEX_HOME` with user-installed skills from the shared `~/.codex/skills/`; when set to **Off** the seed step is skipped, so the Codex CLI sees only workspace skills.
|
||||
|
||||
For every other runtime (Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi / OpenCode / OpenClaw) the daemon leaves the user's `$HOME` untouched and does not actively manage user-level skill discovery — whether that runtime reads any user-level skill directory is entirely up to its own CLI. **The toggle is a no-op for these runtimes today**, regardless of where it is set; a future change can extend per-runtime isolation as upstream CLIs ship the necessary knobs.
|
||||
|
||||
## Importing a skill
|
||||
|
||||
Workspace skills come from four sources:
|
||||
|
||||
@@ -16,6 +16,24 @@ Multica 支持两种 Skill 来源:
|
||||
|
||||
大多数情况用**工作区 Skill**:导入一次,团队所有成员的智能体都能用。本机 Skill 适合先在本地测试、或涉及敏感本地内容的场景。
|
||||
|
||||
### 按智能体维度的"合并本机 Skill"开关
|
||||
|
||||
默认情况下 Multica **会**把本机用户级 skill 目录合并进智能体(保持 MUL-2603 之前的"继承本机"行为,避免误伤依赖本机 skill 的个人智能体)。如果某个共享智能体需要防御"某台机器上有问题的本机 skill"——这种情况会让 Claude CLI 还没读到 prompt 就崩(典型表现是 `broken pipe`,参见 GitHub #3052)——你可以把开关切到 ignore。
|
||||
|
||||
每个智能体都带有一个 `skills_local` 开关,可在 **创建 Agent** 弹窗或智能体的 **Skills** 标签页里切换:
|
||||
|
||||
- **开启 —— Merge(默认)**:运行时直接继承完整的 `~/.claude/`,包括 `skills/`。沿用 MUL-2603 之前的行为,适合个人智能体或明确依赖本机 skill 的场景。
|
||||
- **关闭 —— Ignore**:运行时使用按任务隔离的配置目录,目录里通过软链镜像了主机实际生效的 Claude 配置目录中除 `skills/` 之外的全部内容。源目录的解析顺序为:agent `custom_env` 中的 `CLAUDE_CONFIG_DIR` > 父进程 `CLAUDE_CONFIG_DIR` > `~/.claude/`,因此把 Claude 固定到托管安装目录的运维者仍能拿到正确的凭据和设置。登录凭据(`.credentials.json`)、全局设置、插件、本地 agent 仍会被 CLI 看到,Claude Code 登录态不受影响,只屏蔽用户级 skill 目录。在未开启开发者模式的 Windows 主机上,软链失败时会自动回退为目录联结(junction)以及文件的硬链接/拷贝,保证无管理员权限时镜像仍然可用。**推荐在需要防御本机 skill 风险的共享智能体上启用**。
|
||||
|
||||
无论开关如何,工作区目录下的 `{workDir}/.claude/skills/`(以及其他 runtime 在工作目录下对应的路径)都会照常加载——这个开关只控制本机用户级 skill 目录。
|
||||
|
||||
**目前哪些运行时实际受开关影响:** 守护进程主动管理用户级 skill 发现的运行时是 Claude Code 和 Codex,所以开关对它们都生效:
|
||||
|
||||
- **Claude Code** —— 关闭时把 `CLAUDE_CONFIG_DIR` 指向按任务隔离的目录,里面镜像了主机有效的 Claude 配置目录中除 `skills/` 之外的内容(见上)。
|
||||
- **Codex** —— Codex 一向运行在按任务隔离的 `CODEX_HOME` 下。开启(默认)时守护进程会把共享的 `~/.codex/skills/` seed 到该目录;关闭时跳过这一步,Codex CLI 只能看到工作区 skill。
|
||||
|
||||
对其它运行时(Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi / OpenCode / OpenClaw),守护进程既不改写用户的 `$HOME`,也不主动管理用户级 skill 发现——这些运行时是否读取任何用户级 skill 目录,完全由各自的 CLI 决定。**无论该开关如何设置,对这些运行时都是 no-op**;待上游 CLI 提供必要的隔离开关后,可以再补齐。
|
||||
|
||||
## 导入 Skill
|
||||
|
||||
工作区 Skill 有四种来源:
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
85
apps/mobile/app/_layout.tsx
Normal file
85
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import "../global.css";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Stack, router } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "@react-navigation/native";
|
||||
import { PortalHost } from "@rn-primitives/portal";
|
||||
import { api } from "@/data/api";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { useAuthStore } from "@/data/auth-store";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
import { LightboxProvider, prewarmHighlighter } from "@/lib/markdown";
|
||||
import { NAV_THEME } from "@/lib/theme";
|
||||
import { useColorScheme } from "@/lib/use-color-scheme";
|
||||
|
||||
// Kick off Shiki highlighter init at module load — fires once per process,
|
||||
// finishes before the user navigates to any screen with a code block. If
|
||||
// init fails (engine unavailable) the highlighter falls back to plain
|
||||
// text; nothing here is allowed to throw.
|
||||
prewarmHighlighter();
|
||||
|
||||
function AuthInitializer({ children }: { children: React.ReactNode }) {
|
||||
const initialize = useAuthStore((s) => s.initialize);
|
||||
const qc = useQueryClient();
|
||||
// Idempotent guard: 401 on multiple in-flight requests would otherwise
|
||||
// logout/navigate repeatedly during the same session-expire moment.
|
||||
const signingOutRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Wire 401 handling onto the shared ApiClient singleton. Must be set
|
||||
// before any request fires — initialize() below kicks off the first
|
||||
// getMe() call, so do this synchronously first.
|
||||
api.setOptions({
|
||||
onUnauthorized: () => {
|
||||
if (signingOutRef.current) return;
|
||||
signingOutRef.current = true;
|
||||
void (async () => {
|
||||
await useAuthStore.getState().logout();
|
||||
await useWorkspaceStore.getState().clear();
|
||||
qc.clear();
|
||||
router.replace("/login");
|
||||
// Reset on next tick so a fresh session can hit 401 again later
|
||||
// without being silently swallowed.
|
||||
setTimeout(() => {
|
||||
signingOutRef.current = false;
|
||||
}, 0);
|
||||
})();
|
||||
},
|
||||
});
|
||||
initialize();
|
||||
}, [initialize, qc]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const { colorScheme, isDarkColorScheme } = useColorScheme();
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider value={NAV_THEME[colorScheme]}>
|
||||
<AuthInitializer>
|
||||
<LightboxProvider>
|
||||
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(app)" />
|
||||
</Stack>
|
||||
<PortalHost />
|
||||
</LightboxProvider>
|
||||
</AuthInitializer>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
30
apps/mobile/app/index.tsx
Normal file
30
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "@/data/auth-store";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
|
||||
/**
|
||||
* Entry redirect. AuthInitializer (in _layout.tsx) finishes auth + slug
|
||||
* hydration before this renders meaningfully — until then, isLoading is true.
|
||||
*
|
||||
* no user → /login
|
||||
* user, no slug → /select-workspace
|
||||
* user, slug → /[slug]/inbox
|
||||
*/
|
||||
export default function Index() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-background">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <Redirect href="/login" />;
|
||||
if (!slug) return <Redirect href="/select-workspace" />;
|
||||
return <Redirect href={`/${slug}/inbox`} />;
|
||||
}
|
||||
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 436 KiB |
9
apps/mobile/babel.config.js
Normal file
9
apps/mobile/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
19
apps/mobile/components.json
Normal file
19
apps/mobile/components.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
32
apps/mobile/components/brand/multica-logo.tsx
Normal file
32
apps/mobile/components/brand/multica-logo.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Multica wordmark / sigil. 1:1 vector copy of docs/assets/logo-light.svg —
|
||||
* keep this file and the SVG in sync.
|
||||
*
|
||||
* react-native-svg does not resolve CSS `currentColor`, so callers must pass
|
||||
* `color` explicitly. For theme-aware usage, pair with `useColorScheme` +
|
||||
* `THEME` token from `@/lib/theme`.
|
||||
*/
|
||||
import Svg, { Polygon } from "react-native-svg";
|
||||
import { THEME } from "@/lib/theme";
|
||||
import { useColorScheme } from "@/lib/use-color-scheme";
|
||||
|
||||
interface MulticaLogoProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function MulticaLogo({ size = 48, color }: MulticaLogoProps) {
|
||||
const { isDarkColorScheme } = useColorScheme();
|
||||
const resolvedColor =
|
||||
color ?? (isDarkColorScheme ? THEME.dark.foreground : THEME.light.foreground);
|
||||
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 80 80">
|
||||
<Polygon
|
||||
fill={resolvedColor}
|
||||
points="35,51.1 35,80 45,80 45,51.1 71.8,77.9 78.9,70.8 52.1,44 90,44 90,34 52.1,34 78.9,7.2 71.8,0.1 45,26.9 45,-11 35,-11 35,26.9 8.2,0.1 1.1,7.2 27.9,34 -10,34 -10,44 27.9,44 1.1,70.8 8.2,77.9"
|
||||
transform="translate(5, 5.5) scale(0.87)"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
109
apps/mobile/components/chat/agent-picker-sheet.tsx
Normal file
109
apps/mobile/components/chat/agent-picker-sheet.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Agent picker — bottom Modal listing agents the current user can assign /
|
||||
* chat with. Shown when the user taps `+ New Chat` and the workspace has
|
||||
* more than one usable agent; with exactly one, the chat screen skips this
|
||||
* sheet and goes straight to the blank state for that agent.
|
||||
*
|
||||
* Filtering is delegated to the caller (the screen passes a pre-filtered
|
||||
* `agents` list) so the same filter logic — archived + canAssignAgent +
|
||||
* order — stays in one place.
|
||||
*
|
||||
* Layout mirrors `components/issue/my-issues-filter-sheet.tsx`: transparent
|
||||
* Modal + dimmed backdrop + centered card. Bottom-sheet anchoring would be
|
||||
* nicer but the current codebase doesn't pull in a bottom-sheet lib and
|
||||
* centered cards already work well on iOS.
|
||||
*/
|
||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { ActorAvatar } from "@/components/ui/actor-avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
agents: Agent[];
|
||||
currentAgentId: string | null;
|
||||
onPick: (agent: Agent) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AgentPickerSheet({
|
||||
visible,
|
||||
agents,
|
||||
currentAgentId,
|
||||
onPick,
|
||||
onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Pressable onPress={() => {}} className="w-full max-w-sm">
|
||||
<View className="bg-popover rounded-2xl overflow-hidden">
|
||||
<View className="px-4 py-3 border-b border-border">
|
||||
<Text className="text-base font-semibold text-foreground">
|
||||
Choose an agent
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="max-h-96">
|
||||
{agents.length === 0 ? (
|
||||
<View className="px-4 py-8">
|
||||
<Text className="text-sm text-muted-foreground text-center">
|
||||
No agents available.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
agents.map((agent) => {
|
||||
const selected = agent.id === currentAgentId;
|
||||
return (
|
||||
<Pressable
|
||||
key={agent.id}
|
||||
onPress={() => {
|
||||
onPick(agent);
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
|
||||
selected && "bg-secondary/60",
|
||||
)}
|
||||
>
|
||||
<ActorAvatar type="agent" id={agent.id} size={32} showPresence />
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-sm font-medium text-foreground"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.name}
|
||||
</Text>
|
||||
{agent.description ? (
|
||||
<Text
|
||||
className="text-xs text-muted-foreground mt-0.5"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? (
|
||||
<Text className="text-sm text-primary font-semibold">
|
||||
✓
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
149
apps/mobile/components/chat/chat-composer.tsx
Normal file
149
apps/mobile/components/chat/chat-composer.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Chat composer — thin wrapper around the shared `<MessageComposer>` with
|
||||
* chat-specific wiring:
|
||||
*
|
||||
* - **Controlled text**: parent (chat.tsx) owns the draft via
|
||||
* `useChatDraftsStore` so switching sessions rehydrates the right
|
||||
* draft. Pass `value` + `onChangeText` through.
|
||||
* - **Stop button**: while an agent task is running for the active
|
||||
* session, `sending` flips true and we replace the Send button slot
|
||||
* with a Stop affordance (filled foreground bg + stop glyph). Tap →
|
||||
* `onStop()` cancels the in-flight task.
|
||||
* - **Mention picker mode=chat**: chat is user ↔ single agent so
|
||||
* @member / @agent / @squad / @all are noise + would notify the
|
||||
* wrong people. Picker route honors `?mode=chat` and surfaces only
|
||||
* Issues (useful for "reference this ticket for context").
|
||||
* - **No reply target**: chat is a flat conversation; passes no
|
||||
* reply chip.
|
||||
* - **No upload context**: chat attachments are session-scoped; the
|
||||
* server back-fills `chat_message_id` on each row when the message
|
||||
* persists (server-side). `MessageComposer` calls `api.uploadFile`
|
||||
* without `{ issueId, commentId }`.
|
||||
* - **Parent owns keyboard**: chat.tsx wraps in KeyboardAvoidingView +
|
||||
* SafeAreaView, so `manageKeyboard={false}` prevents the composer
|
||||
* from double-stacking its own keyboard handling.
|
||||
*
|
||||
* Previously a hand-written 400-LOC twin of inline-comment-composer.tsx;
|
||||
* now ~50 LOC plus the StopButton subcomponent.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { MessageComposer } from "@/components/composer/message-composer";
|
||||
import { useWorkspaceStore } from "@/data/workspace-store";
|
||||
import { useColorScheme } from "@/lib/use-color-scheme";
|
||||
import { THEME } from "@/lib/theme";
|
||||
|
||||
interface Props {
|
||||
/** Current draft text (controlled). Empty string = no draft. */
|
||||
value: string;
|
||||
/** Fired on every keystroke. The caller writes to the drafts store. */
|
||||
onChangeText: (next: string) => void;
|
||||
/** Send the serialised markdown content + the completed attachments'
|
||||
* server ids. Caller resets the input by setting `value=""` after a
|
||||
* successful send. */
|
||||
onSend: (content: string, attachmentIds: string[]) => Promise<void> | void;
|
||||
/** Cancel the in-flight agent task. Only callable while `sending===true`. */
|
||||
onStop: () => void;
|
||||
/** True while an agent task is running for the active session. The
|
||||
* composer swaps Send for Stop. */
|
||||
sending: boolean;
|
||||
/** Hard-disable typing + send. Used when there's no usable agent in the
|
||||
* workspace or the session is archived (legacy). */
|
||||
disabled?: boolean;
|
||||
/** When `disabled`, replaces the pill label with the reason. */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
const IS_IOS = process.env.EXPO_OS === "ios";
|
||||
|
||||
export function ChatComposer({
|
||||
value,
|
||||
onChangeText,
|
||||
onSend,
|
||||
onStop,
|
||||
sending,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
}: Props) {
|
||||
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({
|
||||
content,
|
||||
attachmentIds,
|
||||
}: {
|
||||
content: string;
|
||||
attachmentIds: string[];
|
||||
}) => {
|
||||
// `onSend` may be sync or async; await is safe in both cases. If it
|
||||
// throws, MessageComposer's catch restores text + chips.
|
||||
await onSend(content, attachmentIds);
|
||||
},
|
||||
[onSend],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (IS_IOS) {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
}
|
||||
onStop();
|
||||
}, [onStop]);
|
||||
|
||||
return (
|
||||
<MessageComposer
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={onSubmit}
|
||||
mentionPickerPath={{
|
||||
pathname: "/[workspace]/mention-picker",
|
||||
params: { workspace: wsSlug ?? "", mode: "chat" },
|
||||
}}
|
||||
placeholder={sending ? "Agent is working…" : "Message…"}
|
||||
pillLabel={
|
||||
sending
|
||||
? "Agent is working…"
|
||||
: disabled
|
||||
? (disabledReason ?? "Chat unavailable")
|
||||
: "Message…"
|
||||
}
|
||||
pillIcon="chatbubble-ellipses-outline"
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
isSending={sending}
|
||||
renderStop={() => <StopButton onPress={handleStop} />}
|
||||
manageKeyboard={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StopButton({ onPress }: { onPress: () => void }) {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const theme = THEME[colorScheme];
|
||||
return (
|
||||
<Animated.View
|
||||
key="stop"
|
||||
entering={FadeIn.duration(120)}
|
||||
exiting={FadeOut.duration(120)}
|
||||
>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="h-8 w-8 items-center justify-center rounded-full bg-foreground active:opacity-80"
|
||||
hitSlop={12}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Stop agent"
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: theme.background,
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
93
apps/mobile/components/chat/chat-empty-state.tsx
Normal file
93
apps/mobile/components/chat/chat-empty-state.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Empty-state surface shown when the active session has no messages.
|
||||
*
|
||||
* Two modes mirror web (packages/views/chat/components/chat-window.tsx
|
||||
* `EmptyState`):
|
||||
*
|
||||
* - first-time (the workspace has never started a chat) → educate. Tell
|
||||
* the user what chat is for; don't surface starter prompts yet, they
|
||||
* presume context the user doesn't have.
|
||||
* - returning (at least one prior session exists) → starter prompts.
|
||||
* Three taps, three common workflows; tapping prefills the composer
|
||||
* draft so the user can edit before sending.
|
||||
*
|
||||
* Copy mirrors the web `chat.json` namespace 1:1. Mobile doesn't have
|
||||
* i18n yet so the strings are inlined in English — when mobile adopts
|
||||
* i18n the lookup keys (`empty_state.first_time_title` etc.) are already
|
||||
* established on the web side, so the migration is a literal
|
||||
* key-by-key swap.
|
||||
*/
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const STARTER_PROMPTS: { icon: string; text: string }[] = [
|
||||
{ icon: "📋", text: "List my open issues by priority" },
|
||||
{ icon: "📝", text: "Summarize what I did today" },
|
||||
{ icon: "💡", text: "Help me plan what to do next" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
hasSessions: boolean;
|
||||
agentName?: string;
|
||||
onPickPrompt: (text: string) => void;
|
||||
}
|
||||
|
||||
export function ChatEmptyState({ hasSessions, agentName, onPickPrompt }: Props) {
|
||||
// First-time experience: educate before suggesting actions. Starter
|
||||
// prompts here would presume the user already knows what chat is for.
|
||||
if (!hasSessions) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6 py-8">
|
||||
<View className="max-w-xs items-center gap-3">
|
||||
<Text className="text-base font-semibold text-foreground text-center">
|
||||
Chat with your agents
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground text-center">
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
✨ They know your workspace —{" "}
|
||||
</Text>
|
||||
<Text className="text-sm font-medium text-foreground">
|
||||
issues, projects, skills
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground">.</Text>
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground text-center">
|
||||
Ask for a summary, plan your day, or hand off a small task.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Returning user: starter prompts are the fastest path back to action.
|
||||
const title = agentName ? `Hi, I'm ${agentName}` : "Welcome back to Multica";
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6 py-8 gap-5">
|
||||
<View className="items-center gap-1">
|
||||
<Text className="text-base font-semibold text-foreground text-center">
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground text-center">
|
||||
Try asking
|
||||
</Text>
|
||||
</View>
|
||||
<View className="w-full max-w-xs gap-2">
|
||||
{STARTER_PROMPTS.map((p) => (
|
||||
<Button
|
||||
key={p.text}
|
||||
variant="outline"
|
||||
onPress={() => onPickPrompt(p.text)}
|
||||
className="h-auto justify-start px-3 py-2.5"
|
||||
accessibilityLabel={p.text}
|
||||
>
|
||||
<Text className="text-sm text-foreground">
|
||||
<Text className="text-sm">{p.icon} </Text>
|
||||
{p.text}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
439
apps/mobile/components/chat/chat-message-list.tsx
Normal file
439
apps/mobile/components/chat/chat-message-list.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Chat message list — user / assistant bubbles, oldest at top, newest at
|
||||
* bottom. Initial render lands at the bottom; new arrivals auto-scroll
|
||||
* when the user is anchored near the bottom; reading history is never
|
||||
* yanked down.
|
||||
*
|
||||
* Behavioral parity (apps/mobile/CLAUDE.md):
|
||||
* - Render ALL message roles. Unknown role values are downgraded to
|
||||
* "assistant" by ChatMessageSchema's `.catch()`, so this list never
|
||||
* needs to silently drop a row.
|
||||
* - Render `failure_reason` messages with destructive styling — same
|
||||
* boolean as web's destructive bubble + failureReasonLabel().
|
||||
*
|
||||
* v1 simplifications:
|
||||
* - No "Replied in Ns" badge under assistant bubbles (elapsed_ms is
|
||||
* parsed but not displayed). Easy v2 add — show below the bubble.
|
||||
* - No attachment card rendering. Attachments embedded as
|
||||
* `` / `[name](url)` in `content` flow through the existing
|
||||
* markdown renderer.
|
||||
*
|
||||
* Interaction: long-press inside a bubble fires a native iOS
|
||||
* `ActionSheetIOS` (Copy / Select Text / Cancel). While the sheet is on
|
||||
* screen the targeted bubble's border highlights. The assistant branch
|
||||
* has no border baseline because its bubble has no shell — adding a 2px
|
||||
* baseline would shift layout per message. See `useChatMessageLongPress`
|
||||
* in `./message-long-press.tsx`.
|
||||
*
|
||||
* List engine: FlashList v2 (Shopify). FlatList was the original choice
|
||||
* (per the now-outdated "no FlashList" baseline in apps/mobile/CLAUDE.md
|
||||
* — written before FlashList v2 stabilised). FlatList's `scrollToEnd` is
|
||||
* janky on variable-height lists by RN's own docs admission, and our
|
||||
* markdown bubbles render in multiple async passes (Shiki highlight,
|
||||
* image natural-size, lightbox provider injection) — each pass used to
|
||||
* fire onContentSizeChange and trigger another forced scroll, causing
|
||||
* the "open chat → feels stuck" jank. FlashList v2 replaces the manual
|
||||
* scroll dance with `maintainVisibleContentPosition`
|
||||
* (default-on; locks visible item across content changes) +
|
||||
* `startRenderingFromBottom` (initial paint at bottom, no setTimeout
|
||||
* hacks). Cell recycling also keeps scroll-up smooth.
|
||||
*/
|
||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
TaskMessagePayload,
|
||||
} from "@multica/core/types";
|
||||
import type { AgentAvailability } from "@multica/core/agents";
|
||||
import { taskMessagesOptions } from "@/data/queries/chat";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Markdown } from "@/lib/markdown";
|
||||
import { failureReasonLabel } from "@/lib/failure-reason-label";
|
||||
import { formatElapsedMs } from "@/lib/format-elapsed";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useChatSelectStore } from "@/data/chat-select-store";
|
||||
import { useChatMessageLongPress } from "./message-long-press";
|
||||
import { ChatEmptyState } from "./chat-empty-state";
|
||||
import { ChatTimeline } from "./chat-timeline";
|
||||
import { StatusPill } from "./status-pill";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
loading: boolean;
|
||||
/** Has the workspace ever started a chat? Drives empty-state copy. */
|
||||
hasSessions: boolean;
|
||||
/** Currently picked / inherited agent's display name. */
|
||||
agentName?: string;
|
||||
/** Receive a starter-prompt tap. Caller writes into the draft store
|
||||
* (or focuses the composer with the text) — empty state stays neutral
|
||||
* about send vs. preview. */
|
||||
onPickPrompt: (text: string) => void;
|
||||
/** Server-authoritative pending-task snapshot for the active session.
|
||||
* Used to render the live timeline + status line as the last item in
|
||||
* the message stream, mirroring web's
|
||||
* `packages/views/chat/components/chat-message-list.tsx` placement. */
|
||||
pendingTask?: ChatPendingTask | null;
|
||||
/** Live timeline rows for the in-flight task. Already fetched by the
|
||||
* parent so this list doesn't have to manage its own subscription. */
|
||||
liveTaskMessages?: TaskMessagePayload[];
|
||||
/** Resolved availability — drives the StatusPill's "Offline" /
|
||||
* "Reconnecting" stages. Pass `undefined` while loading. */
|
||||
availability?: AgentAvailability;
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
loading,
|
||||
hasSessions,
|
||||
agentName,
|
||||
onPickPrompt,
|
||||
pendingTask,
|
||||
liveTaskMessages,
|
||||
availability,
|
||||
}: Props) {
|
||||
// Top-level selection subscription gates the outer "tap-outside-to-dismiss"
|
||||
// Pressable below. When null, the Pressable stays disabled and every tap
|
||||
// passes through to the list cells / bubble long-press wrappers normally.
|
||||
const selectingId = useChatSelectStore((s) => s.selectingId);
|
||||
|
||||
if (loading && messages.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
// Empty new-chat state. Lives here (rather than the parent screen) so
|
||||
// the empty state and the rendered list share spacing/layout rules.
|
||||
return (
|
||||
<ChatEmptyState
|
||||
hasSessions={hasSessions}
|
||||
agentName={agentName}
|
||||
onPickPrompt={onPickPrompt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show the live trace + status line until the persisted assistant
|
||||
// message lands. Once chat:done writes the assistant row, AssistantRow's
|
||||
// own timeline (read from the same cache entry) owns the render — no
|
||||
// double-rendering.
|
||||
const pendingTaskId = pendingTask?.task_id ?? null;
|
||||
const pendingAlreadyPersisted =
|
||||
!!pendingTaskId &&
|
||||
messages.some(
|
||||
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
|
||||
);
|
||||
const showLiveSection = !!pendingTaskId && !pendingAlreadyPersisted;
|
||||
const showLiveTimeline =
|
||||
showLiveSection && (liveTaskMessages?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
// Outer Pressable owns the "tap anywhere outside the selected bubble
|
||||
// to exit text-selection mode" gesture. Disabled when no message is
|
||||
// selected, so it's a layout-only wrapper and every tap passes straight
|
||||
// through to the FlashList cells. Active state captures any tap that
|
||||
// didn't fire an inner Pressable — bubble cells in selecting mode
|
||||
// render their body without a Pressable wrapper (see `MessageRow`'s
|
||||
// `if (isSelecting) return body;`), so taps on the selected bubble
|
||||
// also dismiss, matching iOS Notes / iMessage behaviour. Scroll
|
||||
// gestures are unaffected (Pressable only intercepts non-drag taps).
|
||||
<Pressable
|
||||
onPress={
|
||||
selectingId
|
||||
? () => useChatSelectStore.getState().clear()
|
||||
: undefined
|
||||
}
|
||||
disabled={!selectingId}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{/* `key` on first message id forces remount on session switch so
|
||||
`startRenderingFromBottom` re-fires and we land at the new
|
||||
session's bottom (instead of inheriting the previous session's
|
||||
scroll position). Cheap because sessions are switched, not
|
||||
re-rendered every keystroke. */}
|
||||
<FlashList
|
||||
key={messages[0]?.id ?? "empty"}
|
||||
data={messages}
|
||||
keyExtractor={(m) => m.id}
|
||||
renderItem={({ item }) => <MessageRow message={item} />}
|
||||
ItemSeparatorComponent={MessageSeparator}
|
||||
ListFooterComponent={
|
||||
showLiveSection ? (
|
||||
<View style={{ paddingTop: 12 }} className="gap-2">
|
||||
{showLiveTimeline ? (
|
||||
<ChatTimeline items={liveTaskMessages ?? []} isStreaming />
|
||||
) : null}
|
||||
<StatusPill
|
||||
pendingTask={pendingTask}
|
||||
taskMessages={liveTaskMessages}
|
||||
availability={availability}
|
||||
/>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
// Outer padding mirrors web's max-w-4xl px-5 py-4 container at
|
||||
// mobile scale. Vertical gap between bubbles handled by
|
||||
// ItemSeparatorComponent (FlashList doesn't honour `gap-*` on
|
||||
// contentContainer the way FlatList's gap-via-NativeWind did).
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
// Chat behavior: initial render at the bottom; when new messages
|
||||
// arrive AND the user is within 20% of the bottom, auto-scroll.
|
||||
// Reading history (further than 20% up) is preserved. This single
|
||||
// prop replaces the entire FlatList-era guard ref dance.
|
||||
maintainVisibleContentPosition={{
|
||||
autoscrollToBottomThreshold: 0.2,
|
||||
startRenderingFromBottom: true,
|
||||
}}
|
||||
// Any user-initiated scroll exits message text-selection mode —
|
||||
// matches iMessage's behavior where scrolling implicitly commits /
|
||||
// dismisses the selection caret. Hooks both drag-start and the
|
||||
// momentum kick after a flick so a fast scroll can't escape.
|
||||
onScrollBeginDrag={() => useChatSelectStore.getState().clear()}
|
||||
onMomentumScrollBegin={() => useChatSelectStore.getState().clear()}
|
||||
// iMessage-style keyboard dismissal: dragging the list pulls the
|
||||
// keyboard down with the finger (iOS); tapping empty space between
|
||||
// bubbles dismisses it. `handled` keeps Pressables inside bubbles
|
||||
// (long-press action sheet etc.) firing normally.
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageSeparator() {
|
||||
return <View style={{ height: 12 }} />;
|
||||
}
|
||||
|
||||
function MessageRow({ message }: { message: ChatMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
const isFailure = !!message.failure_reason;
|
||||
const isSelecting = useChatSelectStore(
|
||||
(s) => s.selectingId === message.id,
|
||||
);
|
||||
const longPress = useChatMessageLongPress(message);
|
||||
|
||||
if (isFailure) {
|
||||
return (
|
||||
<FailureBubble
|
||||
reasonLabel={failureReasonLabel(message.failure_reason)}
|
||||
rawError={message.content}
|
||||
elapsedMs={message.elapsed_ms ?? null}
|
||||
isSelecting={isSelecting}
|
||||
longPress={longPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
// User bubble: same Markdown pipeline as assistant — `@mention`
|
||||
// serialisation `[MUL-1](mention://issue/<id>)`, inline links, and
|
||||
// inline code resolve identically to web's
|
||||
// `packages/views/chat/components/chat-message-list.tsx` user branch.
|
||||
// Width is capped at 80% so the bubble keeps the iMessage-style
|
||||
// trailing alignment instead of stretching across the column.
|
||||
const body = (
|
||||
<View
|
||||
className={cn(
|
||||
"self-end max-w-[80%] rounded-2xl border-2 px-3.5 py-2 transition-colors",
|
||||
isSelecting
|
||||
? "bg-primary/5 border-primary/30"
|
||||
: longPress.isPressed
|
||||
? "bg-muted border-primary/30"
|
||||
: "bg-muted border-transparent",
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
content={message.content}
|
||||
attachments={message.attachments}
|
||||
selectable={isSelecting}
|
||||
compact
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
if (isSelecting) return body;
|
||||
return (
|
||||
<Pressable
|
||||
onLongPress={longPress.onLongPress}
|
||||
delayLongPress={500}
|
||||
>
|
||||
{body}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant: timeline fold + markdown + elapsed caption. See
|
||||
// AssistantRow for why timeline is lifted into its own component.
|
||||
return (
|
||||
<AssistantRow
|
||||
message={message}
|
||||
isSelecting={isSelecting}
|
||||
longPress={longPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted assistant message. Renders:
|
||||
*
|
||||
* - Process-steps fold (from `task-messages` cache; same cache fed by
|
||||
* the live timeline above, so completed runs keep their trace).
|
||||
* - Markdown content (the model's final answer).
|
||||
* - "Replied in Ns" caption when `elapsed_ms` is stamped.
|
||||
*
|
||||
* Web's equivalent is `AssistantMessage` in packages/views/chat/components/
|
||||
* chat-message-list.tsx — same shape, simplified for RN (no inner Tooltip
|
||||
* / Copy button — long-press already exposes Copy via the native action
|
||||
* sheet, and selection mode owns the highlight, so a hover-only Copy
|
||||
* affordance would be redundant on mobile).
|
||||
*/
|
||||
function AssistantRow({
|
||||
message,
|
||||
isSelecting,
|
||||
longPress,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
isSelecting: boolean;
|
||||
longPress: ReturnType<typeof useChatMessageLongPress>;
|
||||
}) {
|
||||
// Read the cached timeline if any. `enabled` (in taskMessagesOptions) is
|
||||
// gated on isTaskMessageTaskId — optimistic id prefixes never fetch, so
|
||||
// freshly-sent messages don't spam the API while waiting for the real
|
||||
// task_id to land. Cached cells (after live timeline finished) return
|
||||
// synchronously with no network roundtrip.
|
||||
const { data: timeline = [] } = useQuery(
|
||||
taskMessagesOptions(message.task_id),
|
||||
);
|
||||
const body = (
|
||||
<View className="gap-1.5">
|
||||
{timeline.length > 0 ? (
|
||||
<ChatTimeline items={timeline} />
|
||||
) : null}
|
||||
<Markdown
|
||||
content={message.content}
|
||||
attachments={message.attachments}
|
||||
selectable={isSelecting}
|
||||
/>
|
||||
{message.elapsed_ms != null ? (
|
||||
<ElapsedCaption variant="replied" elapsedMs={message.elapsed_ms} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
if (isSelecting) return body;
|
||||
return (
|
||||
<Pressable onLongPress={longPress.onLongPress} delayLongPress={500}>
|
||||
{body}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Persistent caption rendered under the assistant bubble / failure bubble
|
||||
// once the server has written `elapsed_ms`. Server computes once at task
|
||||
// completion, so this caption is identical across reloads and clients.
|
||||
function ElapsedCaption({
|
||||
variant,
|
||||
elapsedMs,
|
||||
}: {
|
||||
variant: "replied" | "failed";
|
||||
elapsedMs: number;
|
||||
}) {
|
||||
const label =
|
||||
variant === "replied"
|
||||
? `Replied in ${formatElapsedMs(elapsedMs)}`
|
||||
: `Failed after ${formatElapsedMs(elapsedMs)}`;
|
||||
return (
|
||||
<Text className="text-xs text-muted-foreground/80 mt-1">{label}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureBubble({
|
||||
reasonLabel,
|
||||
rawError,
|
||||
elapsedMs,
|
||||
isSelecting,
|
||||
longPress,
|
||||
}: {
|
||||
reasonLabel: string;
|
||||
rawError: string;
|
||||
elapsedMs: number | null;
|
||||
isSelecting: boolean;
|
||||
longPress: ReturnType<typeof useChatMessageLongPress>;
|
||||
}) {
|
||||
const hasRawError = rawError.trim().length > 0;
|
||||
|
||||
// B6: pass `selectable={isSelecting}` rather than hard-coding
|
||||
// `selectable` — otherwise UIKit's text-selection gesture pre-empts
|
||||
// our long-press handler and the action sheet never fires. Select-mode
|
||||
// cue is the border-tint to primary; bg stays destructive so the
|
||||
// failure signal is never lost.
|
||||
const body = (
|
||||
<View className="self-start max-w-[80%]">
|
||||
<View
|
||||
className={cn(
|
||||
"rounded-2xl border-2 bg-destructive/10 px-3.5 py-2 transition-colors",
|
||||
isSelecting || longPress.isPressed
|
||||
? "border-primary/30"
|
||||
: "border-destructive/30",
|
||||
)}
|
||||
>
|
||||
<Text className="text-xs font-semibold text-destructive">
|
||||
{reasonLabel}
|
||||
</Text>
|
||||
{hasRawError ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<View
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Show error details"
|
||||
className="mt-1 flex-row items-center gap-1 active:opacity-70"
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={12}
|
||||
color="#71717a"
|
||||
/>
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Show details
|
||||
</Text>
|
||||
</View>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<View className="mt-1 rounded bg-muted/40 px-2 py-1.5">
|
||||
<Text
|
||||
className="text-xs text-muted-foreground"
|
||||
selectable={isSelecting}
|
||||
>
|
||||
{rawError}
|
||||
</Text>
|
||||
</View>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</View>
|
||||
{elapsedMs != null ? (
|
||||
<ElapsedCaption variant="failed" elapsedMs={elapsedMs} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
if (isSelecting) return body;
|
||||
return (
|
||||
<Pressable onLongPress={longPress.onLongPress} delayLongPress={500}>
|
||||
{body}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user