Compare commits

..

249 Commits

Author SHA1 Message Date
Naiyuan Qing
fe1140c10a docs(mobile): rewrite self-build framing + fix latent CI errors
Docs: drop the "Multica Apple Developer team" framing (no such team) —
every contributor signs the default bundle id with Xcode's free Personal
Team; the EXPO_BUNDLE_IDENTIFIER_PROD override is just a fallback for the
rare case where the prefix gets squatted in Apple's developer portal.
Touched:
- apps/mobile/README.md (top "Just want to use it" section)
- apps/docs/content/docs/mobile-app.{mdx,zh.mdx}

CI: latent type / lint errors that the prior install-step failure had been
masking — surfaced once dependencies installed cleanly:
- failure-reason-label.ts / run-row.tsx — add the new
  codex_semantic_inactivity enum key from packages/core/types/agent.ts
- schemas.ts UserSchema + EMPTY_USER — add profile_description, timezone
- schemas.ts EMPTY_ISSUE_FALLBACK — add metadata
- profile.tsx — escape apostrophe in JSX text

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:08:48 +08:00
Naiyuan Qing
e113308435 feat(mobile): prod build path + composer/mention/edit polish
Prod build path — lets external users self-build a personal copy against
api.multica.ai's production backend:
- New `prod` variant alongside `dev` / `staging`: `.env.production`,
  `dev:prod` / `ios:device:prod` / `ios:device:prod:release` scripts
- `EXPO_BUNDLE_IDENTIFIER_PROD` shell override in `app.config.ts` for
  contributors not on the Multica Apple Developer team (parallel to
  existing `_DEV` pattern)
- Public docs page `mobile-app.{mdx,zh.mdx}` + Reference entry; README
  gains a top-of-file "Just want to use it" section

Composer refactor:
- Shared `components/composer/message-composer.tsx` shell removes ~400
  lines of duplication between chat-composer and inline-comment-composer
- Mention picker pulled out of inline modal into a Router formSheet route
  (`mention-picker.tsx` + `pickers/mention-picker-body.tsx`), backed by a
  Zustand `mention-draft-store`

Other:
- Issue edit screen (`issue/[id]/edit.tsx`) + reusable description-field
- Chat empty-state and timeline split into dedicated components;
  status-pill / message-list / attachment-row rewrites
- Markdown render tweaks, `lib/format-elapsed.ts`, `ui/collapsible.tsx`
- Realtime / schemas additions for chat session updates; new mention-picker
  stack screen registered in workspace `_layout.tsx`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:30:43 +08:00
Naiyuan Qing
478ed502c3 chore(mobile): fresh-checkout build path — simulator scripts, env consistency
- Track apps/mobile/.env.staging (root .gitignore was swallowing it despite mobile gitignore claiming it was committed). Fresh checkouts can now run *:staging without copying the template first.
- Rename EXPO_BUNDLE_IDENTIFIER → EXPO_BUNDLE_IDENTIFIER_DEV and apply only in the dev variant of app.config.ts. Expo CLI auto-loads .env.development.local on every run regardless of APP_ENV, so a generic name silently leaked a dev's personal bundle id into staging / production builds and collapsed the three variants onto one id. The _DEV suffix + isDev-only branch keeps each variant on its canonical id.
- Add ios:mobile / ios:mobile:staging scripts (root + apps/mobile package.json) so the iOS Simulator path exists end-to-end. Previously the only documented build commands targeted USB devices.
- Rewrite apps/mobile/README.md: 6-row command table, first-time setup section (.env.development.local copy step, EXPO_BUNDLE_IDENTIFIER_DEV note), explicit simulator section, clarify 7-day signing limit applies to device builds only.
- Update root CLAUDE.md mobile commands block to list both simulator and device commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:12:56 +08:00
Naiyuan Qing
36ac5724d7 style(mobile): tighter markdown code sizing + auth layout
- Markdown: inline code 15→14 (match body) and block code 14→13 +
  leading-5. SF Mono is denser than PingFang at the same point size, so
  the +1 inline bump made mono glyphs visibly larger than surrounding
  Latin text; the new sizing matches GitHub Mobile / Linear iOS /
  Notion iOS. The two paths (CodeBlock vs enriched list-nested code)
  now agree on 13px.
- Login + verify: logo 56→32, title text-3xl bold → text-2xl semibold,
  description text-base → text-sm, outer gap-8 → gap-6, brand cluster
  gap-4/2 → gap-3/1. Brings the auth screens in line with iOS native
  Settings / Things 3 / Linear iOS layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:18:25 +08:00
Naiyuan Qing
a69abda373 refactor(mobile): list parity polish + drop new-issue seed params
- my-issues / more issues: drop the RNR Tabs primitive in favour of
  plain Pressable pills (Tabs adds vertical padding + a divider that
  break under the cramped 375pt SE3 layout). "Agents and Squads" pill
  label trimmed to "Agents" — backend predicate unchanged
  (involves_user_id), empty-state copy still mentions "agents or
  squads". Scope counts dropped from pill labels (web's IssuesHeader
  doesn't show them either, and "(123)" suffix overflowed on SE3).
- issue-row: render assignee whenever assignee_type + assignee_id are
  both truthy. Earlier whitelist (member/agent only) silently dropped
  squad assignees; ActorAvatar already handles all four enum values.
- new-issue: remove unused seed_content / seed_actor route params —
  the comment-action-sheet path that fed them no longer exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:18:14 +08:00
Naiyuan Qing
de79037d1f refactor(mobile): comment + chat long-press → ActionSheetIOS, composer pill↔expanded
- Comment long-press: drop react-native-ios-context-menu UIContextMenu
  wrapper in favour of native ActionSheetIOS via a useCommentLongPress
  hook. Removes two native deps (react-native-ios-context-menu +
  react-native-ios-utilities). The "Select text" path still works —
  toggling useCommentSelectStore swaps the bubble's long-press handler
  for selectable text.
- Comment composer: two visual states. Collapsed = pill placeholder
  ("Add a comment, @ to mention…"). Expanded = TextInput + toolbar
  (📎 attach · ➤ send). Adds reply-target-store driven by the long-press
  "Reply" action and an attachment row (composer-attachment-row +
  comment-attachment-list mirror web's data contract).
- Chat: matching ActionSheetIOS long-press (Copy / Select Text / Cancel)
  via message-long-press + chat-select-store; cleared on tab blur via
  useFocusEffect.
- useMentionInput.setText now accepts the React functional updater so
  post-await replacements (upload placeholder → final markdown) don't
  lose the user's intermediate typing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:18:02 +08:00
Naiyuan Qing
e87331e0fc feat(mobile): More popover — pins + workspace switcher
- Pins: pin issues/projects from the header three-dot menu; Pinned list
  in the More popover; mirrors web's pin endpoints + cache shapes.
  Adds data/queries/pins.ts, data/mutations/pins.ts, realtime updater,
  PinListSchema + EMPTY_PIN_LIST fallback.
- Workspace switcher: collapse the per-workspace list in the More
  popover down to a single WorkspaceCard row + pushes a dedicated
  switch-workspace formSheet with an iOS Alert.alert confirm before
  actually switching. Adds friction against accidental taps and keeps
  the popover short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:17:25 +08:00
Naiyuan Qing
f3c4e6686a Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-22 07:59:40 +08:00
Bohan Jiang
41788d2728 fix(settings): i18n the desktop Updates tab (MUL-2515) (#3014)
* fix(settings): i18n the desktop Updates tab (MUL-2515)

The Updates tab in Settings was hardcoded English, so Chinese users
saw a jagged untranslated panel. Wrap the desktop settings route in a
component so the tab label can pull from i18n, move the panel copy to
a new desktop.updates namespace under settings, and translate it for
zh-Hans.

Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): polish zh-Hans Updates tab copy (MUL-2515)

Address review feedback on PR #3014:
- "桌面 app" → "桌面端" to match runtime voice
- "检查中…" → "检查中..." per zh conventions (ASCII ellipsis)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 22:27:40 +08:00
Naiyuan Qing
fbd965e5bf feat(onboarding): v3 — thin server, frontend-orchestrated welcome (#3008)
* feat(onboarding): Multica Helper as general workspace assistant + blocking modal

Reshape Multica Helper from an onboarding-only guide into the workspace's
general-purpose AI assistant. The agent's permanent identity (injected as
`## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md
via execenv.InjectRuntimeConfig) is rewritten to three sections that don't
overlap with what the brief already provides:

  - Who I am (built-in workspace assistant, not onboarding-only)
  - What Multica is + docs/source/issues URLs as knowledge sources
  - What I can do (CLI = manifest, `multica --help` is the source of truth)
  - Tone (concise, like a colleague, match user's language)

Bootstrap moves out of the in-flow Step 4. Runtime step now exits the
onboarding shell with no bootstrap call; a blocking OnboardingHelperModal
mounts inside the workspace layout (web + desktop) and gates purely on
`me.onboarded_at == null`. The user picks one of three starter prompts
(intro / assign / second_agent) and the modal calls
BootstrapOnboardingRuntime with a new optional `starter_prompt` field that
becomes the seeded onboarding issue's description.

Side effects required to make `onboarded_at == null` an honest signal:

  - CreateWorkspace no longer marks onboarded (was atomic with CreateMember).
    The "member exists ⟹ onboarded_at != null" invariant is intentionally
    broken; guards (useDashboardGuard / desktop App.tsx) already tolerate
    this — comments updated to reflect the new contract.
  - AcceptInvitation still marks (invitee skips the modal in someone
    else's workspace). Code comment added warning future removers.
  - resolvePostAuthDestination flips to workspace-presence-first: a user
    with a workspace lands in it regardless of `onboarded_at`, so the
    modal can pick up an interrupted setup on relogin.

Other backend changes:
  - `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…")
  - `onboardingAssistantInstructions` rewritten to the 3-section identity
  - `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune
    cap, empty-falls-back-to onboardingIssueDescription)

Frontend changes:
  - Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a
    persisted step)
  - `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"`
  - `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no
    bootstrap, `onboarded_at` stays NULL so the modal fires
  - Runtime step next-button copy → "Start exploring" / "开始探索"
  - New `packages/views/workspace/onboarding-helper-modal.tsx`:
    Base UI Dialog, dismissible=false, three localized cards, mutation
    invalidates agents + issues queries then navigates to the seeded issue
  - Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and
    `apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`

Tests:
  - Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and
    TestCreateWorkspace_DoesNotMarkOnboarded
  - Frontend: onboarding-helper-modal.test.tsx covers all four gating
    conditions, three-card behavior, mutation pending state, and the
    "no close button" invariant

Compatibility:
  - Already-onboarded users: zero impact (modal can't fire)
  - Invitees: AcceptInvitation still marks → modal can't fire
  - Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire
  - Old desktop / web clients: legacy teammate-step path keeps working
    (bootstrap accepts missing starter_prompt) — the new modal only fires
    on the new frontend bundle
  - Avatar SVG kept (asterisk variant) — no migration of existing Helper
    agents, only newly-created Helpers pick up the new instructions/description

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open

On desktop, App.tsx auto-creates a tab pointing at the user's first
workspace as soon as workspaces.length flips from 0 → 1 (during onboarding
Step 2). The new tab mounts WorkspaceRouteLayout under the overlay,
which mounts OnboardingHelperModal. The modal's Portal renders to
document.body — appearing AFTER the WindowOverlay in DOM order, so its
z-50 wins and the modal floats in front of the still-active onboarding
Step 3 (runtime).

Suppress the modal whenever any WindowOverlay is active. When the overlay
closes (onComplete fires after the user finishes onboarding), the modal
re-evaluates `me.onboarded_at == null` and pops on its own.

Web is unaffected (onboarding flow lives at /onboarding, not under
/[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the
onboarding flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(onboarding): add v2 refactor plan

Captures the design + 8-step implementation order for collapsing the
onboarding state machine: single mark-onboarded entry point, persisted
Step 3 user choice, dumb Modal, single install-runtime seed call site.
Includes old-user compatibility analysis (4 existing gates) and per-PR
risk/rollback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2)

Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN
columns to "user" and the CHECK constraint enforcing the 3-state machine
(unset / picked-runtime / explicit-skip; the fourth combination is
forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades
to "unset" rather than dangling.

PatchUserOnboarding gains the two narg fields plus CASE expressions that
collapse the runtime/skipped pair atomically — a follow-up PATCH that
flips one side now clears the other in the same statement, instead of
preserving it via per-field COALESCE and tripping the CHECK constraint.

Backwards compatible for existing users: both new fields default to
(NULL, false), which is the "unset" leaf of the state machine, and four
upstream gates on me.onboarded_at != null already short-circuit the
new fields' readers for everyone who's already onboarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server): collapse onboarding side effects to service layer

Introduces OnboardingService.MarkComplete and
WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single
authorities for the two onboarding side effects that used to be
duplicated across four handlers:

  - MarkUserOnboarded + claim starter_content_state +
    optional install-runtime fallback seed: was inline in
    BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime,
    AcceptInvitation, and CompleteOnboarding.
  - install-runtime issue seeding: was inline in CreateWorkspace and
    AcceptInvitation as a "no runtime yet" fallback.

After this refactor:
  - MarkUserOnboarded is called from exactly one place (the service).
  - install-runtime issue is seeded from exactly one place (the service).
  - CreateWorkspace deliberately does not seed — the new
    /ensure-onboarding-content endpoint (also added here) lets the
    workspace-entry init component request the seed on first mount, so
    workspaces created but never opened don't accumulate stale issues.
  - The PatchOnboarding handler now accepts the new runtime_id /
    runtime_skipped fields and rejects (uuid, skipped=true) up front.
  - UserResponse exposes the two new persisted fields so the frontend
    can read them off `me` without an extra round-trip.

Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the
explicit cross-request switch path that the original COALESCE design
would have 500'd on) + TestPatchOnboarding_PreserveUntouched.

Old handler-local file no_runtime_issue.go is deleted; its content
moved to service/workspace_content.go with the helpers exported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(core): API + types for persisted onboarding runtime choice

User type / Zod schema gain onboarding_runtime_id (string | null) and
onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated
to match. api.patchOnboarding accepts the new optional fields and the
new api.ensureOnboardingContent endpoint is wired so the workspace
shell can request the fallback seed.

Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and
recordOnboardingRuntimeSkipped() — replace the prior pattern of
Step 3 calling bootstrap directly. They PATCH the user's choice, sync
the auth store, and return. Mutually exclusive on the server side via
the CHECK constraint; the client just ships one intent at a time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal

Replaces OnboardingHelperModal's self-gating render path with a 4-branch
dispatcher that runs once on workspace-shell mount:

  branch 0  me.onboarded_at != null         → ensure install-runtime issue
                                              fallback, render nothing
  branch 1  me.onboarding_runtime_skipped   → SkipBootstrapping component:
                                              loading veil → bootstrap →
                                              navigate. On failure shows
                                              a Retry UI instead of
                                              silently freezing the veil
  branch 2  me.onboarding_runtime_id        → render Modal with the
                                              runtime id from `me` (no
                                              internal list query)
  branch 3  (none of the above)             → useEffect navigate back to
                                              /onboarding so the user
                                              walks Step 3 again

The Modal itself is now a dumb component — receives `workspace` and
`runtimeId` as props, no internal gates, no runtimeListOptions query.
Tests rewritten to cover the props-driven render + pick-card paths;
the prior gating tests move into the new
workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure
behaviour.

Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop
workspace-route-layout. Desktop keeps its `!overlayActive` suppression
guard so the init doesn't portal-jump in front of an active
WindowOverlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): Step 3 records user choice instead of calling bootstrap

handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime
{Choice,Skipped}) and navigates straight into the workspace shell. The
workspace-entry WorkspaceOnboardingInit reads the persisted choice off
`me` and runs the appropriate branch — Step 3 is pure intent capture
with zero side effects on its own.

PATCH must succeed before navigation: if it fails the user stays on
Step 3 with a toast, because navigating with no persisted intent would
land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue
and trigger a redirect loop back to /onboarding.

The prior asymmetry (Connect deferred bootstrap to the workspace, Skip
ran bootstrap inline) is gone — both paths defer to the workspace
shell now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): v3 — thin server, frontend-orchestrated welcome

Collapse v2's persisted runtime-choice fields + 4-branch dispatcher +
OnboardingService/WorkspaceContentService stack down to a single rule:
`onboarded_at` is the only state field, layout hard-gates on it, and the
welcome experience after Step 3 is owned entirely by the frontend.

V3 flow
- Step 3 button: await POST /api/me/onboarding/complete (mark only) +
  park a transient signal in `useWelcomeStore` + navigate
- Workspace layout: hard gate `onboarded_at == null` -> /onboarding
- `<WelcomeAfterOnboarding />` reads the welcome-store signal:
  - runtime path: find-or-create Multica Helper via generic createAgent
    with bilingual instructions from `templates/helper-instructions.ts`,
    blocking modal with 3 starter cards, pick -> createIssue + navigate
  - skip path: provision install-runtime (in_progress) -> agent-guide
    (todo, body embeds install-runtime mention chip) -> follow-up comment
    on install-runtime mentioning agent-guide; then pop celebration
    modal with 🎉 emoji pop animation, 2 read-only preview cards, single
    [Got it] CTA that navigates to install-runtime

Server cleanup
- Drop OnboardingService, WorkspaceContentService, v2 runtime-choice
  columns/CHECK on user, EnsureOnboardingContent endpoint
- CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded
  directly (no service indirection)
- BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a
  deprecation shim in onboarding_shim.go for desktop < v3 during the
  rollout window — handlers inlined to qtx.* calls, no service layer

Localization
- Persisted strings (issue titles/bodies, Helper instructions/
  description, comment prefix) live as TS const `{en, zh}` maps in
  `packages/views/onboarding/templates/` — i18n bundle staleness can no
  longer write raw key paths into DB
- UI-rendered strings (modal copy, status chips, buttons) stay in
  `packages/views/locales/{en,zh-Hans}/onboarding.json`
- Language picked from live `i18n.language` (not `me.language`, which is
  null for new users until they pick a preference)

Race protection
- Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`,
  `postCommentDeduped`) so React StrictMode double-mount can't fire two
  parallel API calls that the server would then 409

Cross-references between the two skip-path issues render via Multica's
mention-chip protocol `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety

Welcome modal polish (the post-Step-3 surface this branch already
introduced):

Runtime path
- Helper avatar replaces the bouncy 🎉 hero; tone-down animation to
  fade. New copy: "Hi, welcome to Multica / I'm your first Agent
  assistant" + capability hint sentence so users discover assignment +
  chat from the first screen.
- Cards changed from "click = submit" to multi-select with the existing
  border-primary + ring selection pattern used by compact-runtime-row;
  bottom CTA "Assign N tasks to me →" appears only with N>0.
- New starter cards: intro / tour / welcome_page (the last one tells
  Helper to paste an HTML welcome page into the issue comment — works
  on any runtime regardless of fs access).
- Success state added between createIssue and navigation: 🎉 +
  "All set!" + "Sit tight  — your {agentName} is on it" + inbox/chat
  hints, single [Got it] button.
- Title/prompt for starter cards now live in TS const
  HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n
  bundle being loaded); subtitle stays in onboarding.json.

Skip path
- Body restructured into three independent ```md blocks (Name /
  Description / Instructions) so each picks up the markdown renderer's
  per-block copy button — no manual extraction.
- ZH body now embeds the ZH Helper Description + Instructions (was
  Chinese-around-English-block).
- Follow-up comment uses Multica's mention-chip protocol
  [identifier](mention://issue/uuid) so it renders as the styled
  IssueChip pill.
- Issue titles bilingual with "Step 1 / Step 2" prefix.

Cross-user / cross-workspace safety (code review feedback)
- web onLogout + desktop handleDaemonLogout now call
  useWelcomeStore.reset() so user B logging into the same browser
  doesn't inherit user A's signal.
- WelcomeAfterOnboarding gates on
  currentWorkspace.id === signal.workspaceId — prevents firing the
  modal in workspace B when the signal was parked for workspace A
  (desktop multi-tab, back/forward, deep-link).
- Module-level promise dedupes (pendingHelperSetup,
  pendingIssueSeed, pendingCommentSeed) for the three API calls so
  React 18+ StrictMode dev double-mount can't race-create duplicates.

Other small fixes carried in this commit
- Helper instructions / agent description / starter card titles all
  read i18n.language (not me.language, which is null for new users
  who haven't picked a UI language preference yet).
- Reverted welcome-emoji-pop animation to a small fade for the runtime
  avatar (kept the bouncy variant for the skip 🎉 hero where the
  celebration is the whole point).
- Removed the duplicate 🎉 from the skip modal title (kept the hero
  one only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(views): i18n hardcoded "Close" in welcome FullScreenError

CI lint (i18next/no-literal-string) blocked on a literal "Close" string
inside `FullScreenError` — surfaced as a nit in the original code
review but missed in the merge. Add `error_close` to onboarding.json
(EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel`
prop, matching the existing `retryLabel` plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:00:26 +08:00
Naiyuan Qing
901bb11e3b feat(mobile): comment surface — inline composer + UIKit context menu + failed-retry + last-viewed divider
Replaces the old route-based comment composition + actions sheet with surface-level UI that matches iMessage / Slack iOS / Telegram conventions.

Long-press on a comment bubble now hands the gesture to UIKit's UIContextMenuInteraction (via react-native-ios-context-menu) — system blur, snapshot scale, grouped menu (Reply / Edit / Copy / Select Text / Copy Link / Resolve / New Issue / Delete), and a Tapback-style auxiliary preview emoji row above the snapshot. Eliminates the race between Pressable.onLongPress and UITextView's selection magnifier that the old formSheet route suffered from.

New inline composer (components/issue/inline-comment-composer.tsx) sits at the bottom of the issue detail screen, pinned just above the keyboard via KeyboardStickyView (react-native-keyboard-controller). Replaces the new-comment.tsx modal route — phone keyboard already gives the composer dedicated real estate, the route + draft store were overhead.

Timeline gains:
- "New since last view" divider driven by data/stores/last-viewed-store.ts
- Failed-comment retry/discard inline affordance backed by data/stores/failed-comments-store.ts (mutation onError keeps the optimistic entry; this store carries retry metadata + error string)

Data layer:
- mutations/issues: useCreateComment accepts attachmentIds, mirrors web's activeIds derivation
- realtime/issue-ws-updaters + use-issue-realtime: WS coverage tweaks for new comment lifecycle
- comment-select-store: extended for the Select Text path triggered from the new context menu

Cleanup of dead route registrations (workspace _layout.tsx) for the removed new-comment, comment/actions, and (already-removed) menu routes.

Adds deps: react-native-ios-context-menu, react-native-ios-utilities, react-native-keyboard-controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:53:07 +08:00
Naiyuan Qing
4a12105bf1 refactor(mobile): drop menu route + global-nav-menu, dropdown only
The More-tab dropdown popover (introduced earlier) now covers everything the dedicated /menu route and global-nav-menu component used to render. Drop both.

The Stack.Screen registration for the menu route in (app)/[workspace]/_layout.tsx is removed in the follow-up comment-surface commit alongside other dead route registrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:52:40 +08:00
Naiyuan Qing
9e4238648e feat(mobile): picker polish + inline label create with deterministic color
- New labels mutation (data/mutations/labels.ts) + createLabel API method (data/api.ts) so the label picker can create-and-attach in one flow without leaving the sheet
- lib/inline-color.ts: deterministic palette hash ported from packages/views label-picker for behavioral parity (same name → same color across web/mobile)
- All issue + project picker bodies (label/priority/status/project on issues; lead/priority/status on projects) reworked for visual + interaction consistency
- Picker route shells (issue/[id]/picker/{label,project}, new-issue-picker/project, project/[id]/picker/lead) updated to match

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:52:27 +08:00
Naiyuan Qing
0d1601be8b chore(mobile): markdown rendering + UI primitive polish
- lib/markdown/{code-block,markdown-style,preprocess}: refined code block rendering, restructured style map, preprocess tweaks
- components/ui/{actor-avatar,text-field}: visual polish
- components/issue/mention-suggestion-bar: tweaks alongside inline composer mention pipeline
- components/editor/use-file-attach: small adjustments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:52:06 +08:00
Naiyuan Qing
c09a096c2d fix(mobile): friendlier auth error messages on login + verify
Adds lib/auth-error.ts that maps backend raw English errors (invalid / expired / rate-limited / network) to user-facing copy. login.tsx and verify.tsx route their catch blocks through it with a per-screen fallback string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:51:55 +08:00
Naiyuan Qing
0948041f26 chore(mobile): EXPO_BUNDLE_IDENTIFIER override + brand logo + CLAUDE.md preflight rules
- .env.example + app.config.ts: optional EXPO_BUNDLE_IDENTIFIER for devs whose Apple ID isn't on the Multica team
- components/brand/multica-logo.tsx: new brand logo asset
- CLAUDE.md: restructured with mandatory pre-flight (read web impl → show plan → wait for go) before any new mobile feature; consolidated behavioral parity rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:51:42 +08:00
Bohan Jiang
cb90249eac test(issues): match new metadata trigger label with count suffix (#3011)
The sidebar metadata trigger now reads "Metadata · N" (#3010), so the
exact-name button query stopped matching and 2 tests went red on main.
Relax the assertion to `/^Metadata\b/` — still anchors on the label but
tolerates the count suffix.
2026-05-21 18:25:36 +08:00
Multica Eve
af13d7ad3a docs: add v0.3.5 changelog (#3006)
* docs: add v0.3.5 changelog

Co-authored-by: multica-agent <github@multica.ai>

* docs: revise v0.3.5 changelog

Co-authored-by: multica-agent <github@multica.ai>

* docs: refine v0.3.5 changelog title

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 18:09:29 +08:00
Bohan Jiang
cbd42dfcc4 feat(issues): align metadata trigger with sibling sections (#3010)
Reshape the sidebar metadata trigger so it visually matches the Pull
requests / Details / Parent issue headers (muted "Metadata · N" row
instead of an icon+label button). Clicking still opens the existing
JSON dialog — folding the bag inline pushed the rest of the sidebar
down too much when the payload was large.
2026-05-21 17:57:27 +08:00
LinYushen
adec90c621 MUL-2510 feat: add delete button to fleet nodes list (#3007)
* feat: add delete button to fleet nodes list

- Add deleteCloudRuntimeNode method to API client (DELETE /api/cloud-runtime/nodes/:nodeId)
- Add useDeleteCloudRuntimeNode mutation hook in cloud-runtime.ts
- Add delete button with Trash2 icon to CloudRuntimeNodeRow component
- Include confirmation dialog, loading state, and toast notifications
- Add i18n keys for en and zh-Hans locales

Co-authored-by: multica-agent <github@multica.ai>

* fix(api): correct deleteCloudRuntimeNode contract to match server

- Change from DELETE /api/cloud-runtime/nodes/:nodeId (no body) to
  DELETE /api/cloud-runtime/nodes with JSON body { id: nodeId }
- Use fetchRaw + Content-Type header to match server's withBody proxy
- Add contract test verifying URL, method, body, and Content-Type

Fixes review feedback on MUL-2510

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:46:26 +08:00
Bohan Jiang
ae530ef057 docs(runtime): tighten issue-metadata write bar (MUL-2507) (#3004)
The previous wording invited agents to pin too much: any opened PR,
external link, or "fact future agents will want one-glance access to"
was framed as worth writing, with no explicit upper bound. In practice
this caused metadata bags to accumulate single-run details and
description-summary noise instead of the small set of repeatedly-read
values the feature was designed for.

Rework the agent runtime brief and the CLI docs to lead with the bar:
write a key only when it is materially important AND likely to be
re-read by future runs on the same issue. "Most runs write zero new
keys" is now stated as the expected case, and the workflow exit step
is rewritten to mirror the same gate. Recommended-key list, safety
boundaries, and stale-key cleanup are preserved so the locked-in test
anchors still pass.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:20:43 +08:00
Bohan Jiang
ab0228c2a1 feat(issues): collapse long metadata bags in sidebar MUL-2503 (#3003)
* feat(issues): collapse long metadata bags in sidebar (MUL-2503)

The metadata KV strip rendered every key inline, so issues with many
pinned keys pushed the rest of the sidebar far down. Keep the first
four rows visible and tuck the remainder behind a Show N more / Show
less toggle once the bag reaches five keys, mirroring the PR list
collapse rule.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): hide metadata behind a JSON dialog (MUL-2503)

Metadata is an agent-facing free-form KV bag — the values almost never
mean anything to a human reader, and every property humans actually care
about already has a dedicated sidebar field (status, priority, assignee,
etc.). Rendering the first four keys inline still pushed real signal
down and added visual noise for no benefit, so drop the inline strip
entirely.

Replace the section with a small `{ }` Metadata button at the bottom of
the sidebar that opens a Dialog showing the formatted JSON. The button
hides itself when the bag is empty, so the common case stays completely
quiet. Removes the prior collapse threshold (and its `Show N more` /
`Show less` strings) since there is nothing to collapse anymore.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:18:57 +08:00
LinYushen
e288eff2c5 feat: server auto-generates PAT for cloud runtime bootstrap (#3002)
When bootstrap is enabled and no PAT is available from the request
header or Authorization bearer token, the server now generates a new
PAT automatically and forwards it to the cloud service.

This removes the need for the frontend to pass X-User-PAT — the
server handles it entirely.
2026-05-21 17:07:44 +08:00
YOMXXX
29c2a5d18f fix(daemon): reclaim stale dispatched claims (MUL-2485) (#2872)
* fix(daemon): reclaim stale dispatched claims

* fix(daemon): widen stale claim reclaim window
2026-05-21 17:06:55 +08:00
Tom Qiao
81e8aa5812 test(core): add unit tests for reserved-slugs (#2985)
Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-05-21 16:54:45 +08:00
Bohan Jiang
0c767c0052 feat(issues): per-issue metadata KV (MUL-2017) (#2845)
* feat(issues): per-issue metadata KV (MUL-2017)

Adds a small JSONB KV map to every issue for agent pipeline state (attempts,
PR number, pipeline status, ...). Keys match a narrow regex, values are
primitives (string / number / bool), capped at 50 keys per issue and 8KB
per blob. Defense-in-depth via two CHECK constraints (object shape + size).

All mutations are single-key atomic (jsonb_set / `- key`). `UpdateIssue`
intentionally does NOT touch metadata: a whole-blob overwrite would race
with concurrent agent writes.

  GET    /api/issues/:id/metadata
  PUT    /api/issues/:id/metadata/:key   body: { "value": <primitive> }
  DELETE /api/issues/:id/metadata/:key

Containment filter on list: GET /api/issues?metadata=<json-object> uses
PG `@>` against a `jsonb_path_ops` GIN index. Mirrored across ListIssues,
CountIssues, ListOpenIssues, and the hand-rolled ListGroupedIssues SQL so
CLI/API and UI grouped views stay consistent.

CLI: multica issue metadata {list,get,set,delete}
  multica issue list --metadata key=value (repeatable, AND)
  set has --type to override the default value-sniffing
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): metadata test bugs + wire realtime + read-only display (MUL-2017)

- Fix two failing handler tests blocking backend CI:
  - reset decode target after delete so map merge does not mask removal
  - url.PathEscape the key segment so spaces no longer panic NewRequest
- Wire issue_metadata:changed end to end so the detail / list / my-issues
  caches stay in sync with set/delete events (other tabs, CLI writes).
- Add a read-only Metadata strip to the issue detail sidebar; hidden when
  the issue has no keys so it stays quiet in the common case.

Co-authored-by: multica-agent <github@multica.ai>

* feat(runtime): teach agents to read/write issue metadata (MUL-2017)

Add an `## Issue Metadata` section to the runtime brief plus a
`metadata list` step on entry and a `metadata set`/`delete` step on
exit. Section only emits when the task carries an issue id (comment- or
assignment-triggered); chat / quick-create / run-only autopilot stay
clean so they don't fire failing CLI calls.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): bump metadata migration to 105 and drop attempts as example (MUL-2017)

main is now at 104_drop_runtime_timezone; the migrator picks
LatestVersion() by sorted filename, so a slot before the tail would
let DBs that have already run 099–104 think they're up-to-date while
the issue.metadata column is missing — runtime would then fail with
column does not exist. Renumbering to 105 puts the migration at the
tail and forces it to run.

Also drop attempts as a positive example across docs/code comments and
test fixtures — the runtime instruction prompt already lists it under
"What NOT to pin" (runtime bookkeeping). Replace with pr_number, which
is in the recommended-keys set, so docs/tests speak the same language
as the prompt.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:35:45 +08:00
Multica Eve
66c0464140 fix: simplify cloud runtime create form (#3000)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:34:11 +08:00
Bohan Jiang
9a5d8a52f3 fix(timezone): harden hourly-rollup rollout against straight-through migrate MUL-2488 (#2998)
* fix(timezone): harden hourly-rollup rollout against straight-through migrate

MUL-2488

PR #2968 introduced the new task_usage_hourly rollup but assumed operators
would stop migrate between 102 and 103 to run the one-shot
cmd/backfill_task_usage_hourly. Two pieces made that unsafe in practice:

1. The Dockerfile only shipped server / multica / migrate, so a deployed
   container has no backfill binary to run between phases.
2. cmd/migrate has no per-version stop, and entrypoint.sh runs `migrate up`
   to the latest version, so 103 silently drops the legacy daily rollups
   even when nobody ran the backfill — leaving usage dashboards at zero
   despite source data being intact in task_usage.

Changes:

- Build cmd/backfill_task_usage_hourly into the runtime image alongside
  the other binaries so operators can `docker exec` the backfill instead
  of needing a source checkout.
- Add a fail-closed plpgsql guard at the top of migration 103 that
  aborts the migration when task_usage has rows but task_usage_hourly is
  empty. Fresh databases (no task_usage rows) are exempt because the new
  triggers from 102 will populate the hourly table on the first event.

Already-applied databases are unaffected — schema_migrations tracks by
version only, so 103 is not re-run.

Co-authored-by: multica-agent <github@multica.ai>

* fix(timezone): use watermark coverage for hourly-rollup guard

The previous check only required `task_usage_hourly` to be non-empty,
which an interrupted backfill or a manual `rollup_task_usage_hourly_window`
call both satisfy. The completion signal we actually trust is
`task_usage_hourly_rollup_state.watermark_at` — backfill only stamps it
to `now() - 5 min` after every monthly slice succeeded, and the cron
worker only advances it on a real tick. Default after migration 101 is
`1970-01-01`, so an unrun or partial backfill is trivially detected.

Also corrects the comment about fresh-install behavior: the triggers in
102 only enqueue dirty keys for agent_task_queue / issue / task_usage
DELETE — they do not write hourly rows. INSERT/UPDATE flows through the
`updated_at` watermark window of `rollup_task_usage_hourly()`, which
only runs once the operator registers it as a pg_cron job.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:26:42 +08:00
Multica Eve
51b3c5291f feat: add env-gated cloud runtime launcher (MUL-2453) (#2995)
* feat: add env-gated cloud runtime launcher

Co-authored-by: multica-agent <github@multica.ai>

* fix: address cloud runtime frontend nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:41:31 +08:00
Bohan Jiang
51c6e90363 docs: finish /projects link fix + tidy AWS_ENDPOINT_URL description (#2996)
Followup to #2979. One missed /issues → /projects link in agents.mdx
plus two AWS_ENDPOINT_URL row nits (URL/URLs repetition and trailing
period) in SELF_HOSTING_ADVANCED.md and the Chinese self-hosting page.

MUL-2498

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:35:39 +08:00
YYClaw
614dfae884 MUL-2488 feat(timezone): Scheduling / Viewing two-layer timezone architecture (#2968)
* docs(timezone): add scheduling/viewing timezone architecture RFC

* feat(db): replace daily rollups with task_usage_hourly, add user.timezone

Migrations 100-104: add "user".timezone (Viewing tz), build the UTC
hourly task_usage_hourly rollup with its pipeline, drop the legacy
task_usage_daily / task_usage_dashboard_daily pipelines, and drop the
agent_runtime.timezone column. Report queries now slice day boundaries
at read time by the caller-supplied @tz instead of materialising in a
fixed tz. Regenerate sqlc.

* feat(server): add task_usage_hourly backfill command

Replace the two legacy backfill commands (daily / dashboard_daily) with
a single backfill_task_usage_hourly that loads historical task_usage
into the new UTC hourly rollup, sliced per workspace.

* refactor(server): resolve viewing timezone in report handlers

Report handlers resolve the Viewing tz per request (?tz query param,
then user.timezone, then UTC) and pass it to the hourly-rollup queries.
Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup
dual paths, remove the /api/usage endpoints, and stop the daemon from
reporting and the runtime handler from accepting host timezone.

* refactor(core): switch report queries to viewing timezone

API client and dashboard/runtime queries send ?tz with each report
request, the user schema/types carry the new timezone field, and the
runtime timezone field/mutation is removed.

* feat(views): add viewing timezone preference and UI

Add the useViewingTimezone hook and a Timezone setting in Preferences;
report charts and the dashboard week boundary follow the viewer tz.
Remove the runtime detail timezone editor and its locale strings.

* fix(test): update fixtures and stabilize tests for timezone refactor

The timezone architecture refactor changed several types without
updating dependent test code:

- RuntimeDevice no longer has a timezone field — drop it from the
  create-agent-dialog runtime fixture.
- User now requires a timezone field — add it to the apps/web mockUser
  fixture.
- The PreferencesTab timezone tests asserted on the async save handler
  (PATCH then store update) with a bare expect, racing the mutation's
  settle callback, and timed out querying the Select's ~600-option IANA
  list on a loaded CI runner. Wrap the assertions in waitFor and extend
  the timeout for those three tests.

* docs(timezone): document self-host migration order and trigger invariant

Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package
comment: applying migrations 100-104 in a single migrate-up drops the
legacy daily rollups before the hourly backfill runs, leaving dashboards
empty until cron catches up.

Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id
must be added to the trigger's OF list if it ever becomes mutable,
otherwise dirty buckets for the old agent_id are silently missed.

* style(runtimes): drop trailing blank line in runtime-detail
2026-05-21 15:33:47 +08:00
Tom Qiao
d0666138ec docs: fix broken anchor links and truncated env-var description (#2979)
Three docs issues spotted while reading:
- agents.mdx and agents.zh.mdx: [project](/issues) -> [project](/projects)
- cloud-quickstart.mdx: troubleshooting anchor #daemon-cant-reach-the-server
  did not exist; the heading is "Daemon can't connect to the server"
- SELF_HOSTING_ADVANCED.md and getting-started/self-hosting.zh.mdx:
  AWS_ENDPOINT_URL row description was truncated; append " URLs."

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
2026-05-21 15:32:58 +08:00
Multica Eve
41cb91abd9 feat: add cloud runtime fleet proxy API (MUL-2453) (#2986)
* feat: add cloud runtime fleet proxy API

Co-authored-by: multica-agent <github@multica.ai>

* test: cover cloud runtime handler nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:06:10 +08:00
Bohan Jiang
1c892aa3f9 fix(projects): default project view to compact (#2975)
The compact view was the original list layout and is what users expect
on this page; the post-#2840 default of comfortable changed long-standing
behavior. Reset the unpersisted default (and the cross-workspace fallback
in `merge`) back to compact. Updates the view-store tests accordingly.

MUL-2464

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 14:07:40 +08:00
Anderson Shindy Oki
65feb890b8 feat: Add project list responsive compact and comfortable views (MUL-2464) (#2840)
* feat: Add project screen compact and comfortable views

* wip

* i18n

* refactor and add search

* refactor
2026-05-21 13:56:11 +08:00
兰之
7e55813460 fix(ui): show tooltip when create-issue button is disabled due to empty title (#2943)
Co-Authored-By: Xiaomi MiMo V2.5 Pro
2026-05-21 13:43:31 +08:00
Bohan Jiang
7f9e4e829d feat(comments): thread-internal --tail pagination + reply cursor (MUL-2421) (#2846)
* feat(comments): thread-internal pagination via --tail + reply cursor (MUL-2421)

Long threads inside a single issue still forced agents to read every reply
once they used --thread, even after MUL-2387 fixed cross-thread noise. This
adds reply-level paging so a 200-reply thread can be navigated tail-first
without dragging the whole conversation into prompt context.

- New SQL query ListThreadCommentsForIssuePaged: same recursive root walk
  as the legacy thread query, but caps reply count and supports an
  (created_at, id) composite cursor. Root is unconditional — even tail=0
  emits it so the reader keeps the "what is this thread about" context.
- Handler ListComments: parses `tail` (non-negative, ThreadTailSet flag
  preserves the tail=0 intent), threads it through to the paged query,
  and re-uses X-Multica-Next-Before / X-Multica-Next-Before-Id for the
  reply cursor. Cursor's meaning is now context-dependent: thread cursor
  under --recent, reply cursor under --thread + --tail.
- CLI: new --tail flag (only valid with --thread; mutually exclusive
  with --recent), reply-cursor semantics for --before / --before-id when
  paired with --thread + --tail, stderr label flips to "Next reply cursor"
  so an operator copy-pasting the cursor knows which scope it scrolls.
- Tests cover the new contract: tail=N keeps newest N + root, tail=0 is
  root-only, anchor on a nested reply still walks up, reply cursor
  scrolls older replies page-by-page, since combined with tail filters
  after the cut, and the negative-flag-combination matrix.

Out of scope: prompt template update to hint at `--thread <id> --tail 30`
on long threads — separate follow-up per the issue.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): only emit reply cursor when older reply exists (MUL-2421)

The thread-tail path emitted `X-Multica-Next-Before` whenever the page
filled to exactly the requested reply count, even when there was nothing
older to scroll to. So `--thread <root> --tail 3` on a thread with
exactly 3 replies sent a cursor that, when followed, returned just the
root — a wasted round-trip that surfaced as a phantom "older replies"
affordance in the agent prompt.

Switch to a `reply_limit + 1` probe: ask the SQL for one extra row, trim
the oldest overflow before responding, and only emit the cursor when an
older reply actually existed. The exact-boundary case (replyCount ==
tail with no overflow) now returns no cursor.

Also documents `--thread/--tail/--recent/--before` and the cursor
semantics in CLI_AND_DAEMON.md, which was the second must-fix in the
MUL-2421 review.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): suppress reply cursor when --since covers older replies (MUL-2421)

In the thread + tail + since path the server still emitted a reply cursor
whenever there was an older reply on disk, regardless of `since`. If the
oldest retained reply on the page was already `<= since`, every older
reply was guaranteed to be filtered out too, so the next page only ever
returned the root — wasting round-trips until the agent walked the whole
pre-`since` history. Mirror the recent + since suppression: when
`replies[0].CreatedAt <= since`, drop the cursor.

Test covers the exact case from Elon's review: tail=2 overflow, body
keeps a fresher reply, but the cursor target (oldest retained reply) is
already past `since` — header must be empty.

Co-authored-by: multica-agent <github@multica.ai>

* feat(prompt): default comment-trigger reads to --thread --tail 30 (MUL-2421)

Comment-triggered agents previously defaulted the trigger-thread read to
the unbounded `--thread <id> --output json`, which dumps the full thread
into the prompt — exactly the kind of context bloat MUL-2387 fixed at the
cross-thread layer but never bounded inside a single thread.

Use the new `--tail` flag landed earlier in this PR (server + CLI) as the
default for both the per-turn prompt and the runtime-config Workflow:

- `--thread <trigger-id> --tail 30 --output json` is the new default.
  Root is always included so "what is this about" context survives.
- If 30 replies aren't enough, the prompt now spells out the reply
  cursor: re-feed the stderr `Next reply cursor: --before <ts>
  --before-id <reply-id>` pair back to walk older replies.
- `--recent 20` stays as the cross-thread background fallback, with an
  explicit callout that the same `--before` / `--before-id` flags walk
  *threads* (not replies) in that mode.
- Available Commands core line now surfaces `--tail N` and both stderr
  cursor labels so non-workflow callers also discover the flag.
- `--since` callouts reflect the post-MUL-2421 combinable mode names
  (`--thread --tail` / `--recent`).

Tests (`prompt_test.go`, `execenv_test.go`) pin the new defaults and add
a regression guard against the unbounded `--thread` recipe sneaking back
in.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:43:15 +08:00
Bohan Jiang
8a135d2982 fix(ws): truncate unparseable frame payload in client warn log (#2974)
The post-#2946 onmessage guard logs the raw event.data alongside the
warning. A malformed or rogue server can stream arbitrarily large
garbage and bloat the renderer / desktop main-process log buffers, so
cap the logged payload to the first 200 chars and append a
"(truncated, N chars total)" suffix when truncation occurs.

MUL-2490

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:37:42 +08:00
YOMXXX
83e90c9530 fix(ws): log auth frame write failures (#2946) 2026-05-21 13:33:12 +08:00
Bohan Jiang
ef6a944063 fix(cli): accept slug + short UUID prefix in workspace get/update/member (#2972)
* fix(cli): accept slug + short UUID prefix in workspace get/update/member (MUL-2385)

`workspace list` shows the 8-char short UUID prefix, name, and slug by
default; `workspace get`/`update`/`member list` only accepted full UUIDs.
That broke the natural list -> get flow: every value the user could copy
from list output was rejected. They had to either rerun list with
`--full-id` or parse the JSON output -- both implementation-detail level
operations.

Extend `resolveWorkspaceByIDOrSlug` with a short UUID prefix fallback
(>=4 hex chars, ambiguous matches return all candidates), introduce
`resolveWorkspaceRef`/`resolveWorkspaceArg` helpers that fetch the
caller's accessible workspaces and resolve UUID/slug/prefix in one call,
and wire them into get/update/member list (switch already used the same
list-then-resolve pattern). Full UUIDs short-circuit the extra
`/api/workspaces` round trip; access control remains on the downstream
endpoint.

Also add a one-line tip after `workspace list` table output pointing
users at get/update/switch with the same identifier columns, and
broaden the command Use strings to `<id|slug|prefix>` so help reflects
the new behavior.

Refs https://github.com/multica-ai/multica/issues/2750

Co-authored-by: multica-agent <github@multica.ai>

* chore(cli): include prefix hint in workspace list footer

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:08:44 +08:00
YOMXXX
ed2957ddf8 fix(claude): record result model usage (#2899) 2026-05-21 13:00:12 +08:00
iYuan
2f1f90c11a fix(agent): retry codex semantic inactivity fresh (#2593) 2026-05-20 20:03:39 +08:00
Naiyuan Qing
b5913b0a0e wip(mobile): in-flight comment-select / chat / markdown work
Batch commit of pre-existing uncommitted work carried forward alongside
the assignee picker refactor. Topics mixed — split into proper atomic
commits when each lands.

- apps/mobile/data/comment-select-store.ts: new comment-selection store
- components/issue/comment-card.tsx + issue/[id].tsx + comment actions:
  comment-select wiring
- components/chat/chat-message-list.tsx: chat list rework (~170 lines)
- lib/markdown/markdown.tsx: markdown adjustments
- package.json + pnpm-lock.yaml: dependency drift

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:14:02 +08:00
Naiyuan Qing
c19bf6612a feat(mobile): native iOS assignee picker — search bar + pin selected + checkmark accessory
- Switch assignee picker (issue + new-issue) from body-rendered header to
  native Stack header + UISearchController via headerSearchBarOptions.
- Body becomes pure FlatList — fixes react-native-screens#3634 overlap
  (FlatList now route's direct child, no intermediate wrapper view).
- Pin currently-selected actor + Unassigned to the top when no query;
  search results stay in member → agent → squad order.
- Inline right-aligned "Agent" / "Squad" tag mirrors Apple's Value-1 cell
  style (UIListContentConfiguration.valueCell) used throughout Settings.
- Selection indicator: Ionicons checkmark in primary tint only, no row
  bg highlight (Apple HIG: never use selection to indicate state).
- Avatar 28pt → 36pt.
- autoFocus on search bar for search-first pickers — keyboard appears on
  mount, opt-in via hook option.
- Extract useNativeSearchBar + useScrollToTopOnChange hooks under
  apps/mobile/lib/ for phase-2 rollout to label / project / lead pickers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:13:50 +08:00
Bohan Jiang
688dcb017c fix(agents): drop confusing "default" badge from model picker (MUL-2477) (#2938)
The model dropdown already exposes a "Default (provider)" option meaning
"follow the CLI's current selection". Tagging the runtime's preferred
model with a small "default" chip created two competing notions of
"default" in the same UI and confused users. Remove the chip from both
the create-agent ModelDropdown and the inspector ModelPicker; keep the
underlying RuntimeModel.default flag intact since thinking-prop-row
still uses it as a fallback heuristic.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 18:07:57 +08:00
Multica Eve
cf000d1e93 docs(changelog): add 2026-05-20 release notes (#2932)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:28:08 +08:00
Naiyuan Qing
317bca40c1 feat(squads): show skeleton on squad detail initial load (#2930)
Replaces the plain "Loading..." text fallback in SquadDetailPage with a
skeleton that mirrors the loaded page's two-column layout (left inspector
+ right tabs panel), matching the SquadsListSkeleton work shipped in #2890.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:21:52 +08:00
Bohan Jiang
8d4f4caf4a MUL-2338 fix(comments): allow agent self-mention to enqueue cross-issue handoff (#2928)
* fix(comments): allow agent self-mention to enqueue cross-issue handoff

The @mention path in CreateComment unconditionally skipped any
self-mention. That dropped the child→parent handoff between issues
assigned to the same agent: the child run posted `@J` on the parent
issue, the guard tripped, and the parent's J was never woken — the chain
silently broke.

Drop the self-trigger `continue` in the agent mention branch. Runtime
ready / private-agent gate / HasPendingTaskForIssueAndAgent dedup all
remain, so a same-issue self-mention while a queued or dispatched task
exists is still deduped; a running task no longer pre-empts a new
follow-up (the existing queue coalescing handles that).

Three regression tests:
  - cross-issue self-mention enqueues a task on the target issue
  - same-issue self-mention while running queues a follow-up
  - same-issue self-mention with a pre-existing queued/dispatched task
    is deduped

MUL-2338

Co-authored-by: multica-agent <github@multica.ai>

* test(handler): assign per-workspace issue number in self-mention fixture

The fixture inserts two issues in the same test workspace; without an
explicit number both default to 0 and the second insert violates
uq_issue_workspace_number, taking the backend CI job down on PR #2928.

Mirror the workspace-counter advancement pattern from
issue_scheduled_test.go so each fixture issue gets a unique number.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:18:41 +08:00
YOMXXX
34f16e2c7a fix(opencode): deny interactive questions in daemon mode (#2878)
* fix(opencode): deny interactive questions in daemon mode

* fix(opencode): avoid permission env ordering bypass
2026-05-20 17:17:31 +08:00
Naiyuan Qing
85e363370e Revert "feat(issues): Working filter + agent-working badge on board (MUL-2452…" (#2927)
This reverts commit dee5c7cf50.
2026-05-20 16:47:41 +08:00
Naiyuan Qing
b040165f4e feat(squads): skeleton loader + AlertDialog archive confirm (MUL-2437) (#2890)
* feat(squads): skeleton loader + AlertDialog archive confirm (MUL-2437)

- Replace `Loading...` text on the squads list with a Skeleton placeholder
  matching the SquadCard shape (avatar + title + subtitle), aligning with
  the Agents / Dashboard pattern.
- Replace the native `confirm()` on the squad detail Archive button with
  the project's AlertDialog (destructive variant, pending-disabled, i18n
  copy interpolating the squad name).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(squads): drop misleading restore copy from archive confirm (MUL-2437)

Archive is irreversible — there is no unarchive command (see
apps/docs/content/docs/squads.mdx:113). Aligns dialog copy with
docs: tells the user the action can't be undone and to create a
new squad if they need the routing back.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:43:58 +08:00
Naiyuan Qing
dee5c7cf50 feat(issues): Working filter + agent-working badge on board (MUL-2452) (#2924)
* feat(issues): surface "agent working" on board + add Working filter (MUL-2452)

Adds a brand-color "agent working" badge to board cards / list rows so
users can see at a glance which issues have an active agent task, plus a
new "Working" toggle on the `/issues` and `/my-issues` headers (next to
the existing scope segmented control) that filters to those issues. The
toggle shows an avatar stack of the agents currently active on the
current surface + scope. Pure frontend: re-shapes the existing
workspace-wide `agentTaskSnapshot` cache via two new selectors
(`activeTasksByIssueOptions` / `workingIssueIdsOptions`), no new SQL,
endpoint, or DB field; WS `task:*` events already invalidate the
snapshot so the badge / filter update in realtime.

Project detail page keeps the per-card badge but intentionally omits the
header toggle (`showWorkingToggle={false}`) to leave the project
surface's filter dimensions unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): working filter column header reflects filtered count (MUL-2452)

Assignee-grouped board column headers kept showing the unfiltered cache
total when Working was on, because `PaginatedAssigneeBoardColumn` passed
`useLoadMoreByAssigneeGroup`'s cache-derived `total` straight to
`BoardColumn`. The hook still needs the cache total for hasMore, but the
displayed count must follow the visible-after-filter set.

Split the two: when Working is active the column header now uses
`group.totalCount` (set by applyWorkingFilterToGroups) for the assignee
path, and `issueIds.length` for the status path. Load-more keeps reading
from cache so paginated columns still see the full server total.

Regression tests cover applyWorkingFilterToGroups (total rewrite +
empty-group preservation), filterIssues workingOnly combinations, and an
end-to-end assertion via IssuesPage that proves the column header equals
the filtered count, not the cached value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:35:58 +08:00
Bohan Jiang
aeb284cbeb feat(runtime): teach agents the parent/sub-issue protocol (MUL-2338) (#2918)
* feat(runtime): teach agents the parent/sub-issue protocol (MUL-2338)

Adds a Parent / Sub-issue Protocol section to the runtime brief built by
`buildMetaSkillContent`, emitted whenever the agent is running on a real
Multica issue (assignment- or comment-triggered). Two behaviors are now
documented for every issue-bound agent:

- A. When wrapping up a child issue, post the final result and switch to
  `in_review` on this issue first, then post a single top-level comment
  on the parent. Mention the parent assignee only when it is another
  agent on a still-open parent — never self-mention, never @ member /
  squad, never re-trigger a `done` / `cancelled` parent.
- B. When creating sub-issues, choose `--status backlog` for sub-issues
  that must wait and `--status todo` for the one to start immediately;
  promote with `multica issue status <id> todo` when its turn comes.

The signal is explicitly framed as best-effort — no server-side state
sync, no claim of a guaranteed handshake. The section is skipped for
chat, quick-create, and run-only autopilot runs, which have no
parent/child semantics.

Tests in runtime_config_test.go assert that the section is present in
both issue workflows, absent in the three non-issue modes, and that the
wording does not introduce a non-existent `multica issue list --parent`
command or promise a reliable handshake.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): split Step A of parent/sub-issue protocol by trigger type (MUL-2338)

Comment-triggered runs were inheriting an unconditional
`multica issue status <this-issue-id> in_review` from Step A, which
conflicts with the comment-triggered workflow rule "Do NOT change the
issue status unless the comment explicitly asks for it" (Elon's blocking
review on PR #2918). Step A now branches on trigger type:

- Assignment-triggered: keep "post final results + flip in_review".
- Comment-triggered: complete the reply per the existing workflow rule,
  only flip status when the triggering comment asked for it, and gate
  the parent-notification steps on actually closing out child work.

Tests lock the boundary: comment-triggered briefs must not contain the
unconditional in_review command, must echo the existing status
guardrail inside Step A, and must spell out the "closing out" gate.
Assignment-triggered briefs still carry the unconditional flip.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): simplify parent/sub-issue mention rule to always @ parent assignee (MUL-2338)

Per Bohan's directive on PR #2918: the per-case mention table (same agent /
member / squad / closed parent) is overkill prompt complexity. Replace it
with a single rule: always @mention the parent's assignee using the URL
that matches assignee_type. The platform's existing run dedup handles
re-triggers, and a single rule is easier for agents to follow predictably.

Preserves the existing comment-triggered boundary (Step A still does NOT
add an unconditional in_review flip on comment-triggered runs).

Co-authored-by: multica-agent <github@multica.ai>

* refactor(runtime): compress parent/sub-issue protocol to 3-rule convention (MUL-2338)

Drop the spec-flavored A/B sub-headings and per-case mention table; keep
three numbered rules (close out child, notify parent, pick backlog vs
todo) plus a one-line best-effort preamble. The comment-triggered
branch still re-asserts the "do not change status unless asked"
guardrail and gates parent notification on actually closing out child
work; the assignment-triggered branch still flips to `in_review`.

Section is now 7 lines instead of 29. A new TestParentSubIssueProtocolIsCompact
guards the ≤10-line ceiling so this stays a convention, not a spec.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): make sub-issue creation rule unconditional in parent/sub-issue protocol (MUL-2338)

Elon's review on PR #2918: the preamble previously gated all three
rules on the current issue having `parent_issue_id`, but rule 3
(creating sub-issues) needs to reach top-level parents that have no
parent themselves — that is exactly where the `todo` vs `backlog`
decision matters most. Move the gate from the preamble onto rules 1
and 2 per-rule; rule 3 now applies to any issue-bound run. Section
stays at 7 newlines (≤10).

Co-authored-by: multica-agent <github@multica.ai>

* refactor(runtime): unify parent/sub-issue protocol as mechanism description (MUL-2338)

Drop the if/else split between assignment- and comment-triggered runs in
the Parent / Sub-issue Protocol section: both runs now read the same
two-rule description of how the parent/child mechanism works. The
comment-triggered workflow rule "Do NOT change the issue status unless
the comment explicitly asks for it" naturally short-circuits the parent
notification (no status flip → not closing out the child → skip), so the
protocol no longer needs to branch on TriggerCommentID.

Tests collapse the two trigger-specific cases into one parameterized
test, and the assignment vs comment status-flip invariants are now
anchored on the real workflow command (with substituted issue id)
instead of the protocol's removed `<this-issue-id>` placeholder.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:20:33 +08:00
Angular
1f978bf1ec feat(autopilot): link created issues to projects (#2908)
* feat(autopilot): link created issues to projects

* test(autopilot): cover project flag
2026-05-20 15:37:23 +08:00
Bohan Jiang
ffc0c5ab2e docs(agent-inspector): sync thinking_level comments with no-override semantics (MUL-2339) (#2923)
Follow-up to #2919 review nits — comments still described the empty
thinking_level as "use runtime default" and claimed ThinkingPicker callers
guaranteed non-empty levels. Both were stale after the semantics changed:

- packages/core/types/agent.ts: clarify that "" clears the override and
  the local CLI config / built-in default decides at runtime.
- thinking-picker.tsx: document that the stale-orphan clear path in
  ThinkingPropRow mounts the picker with an empty levels list plus a
  persisted value, so callers do not guarantee non-empty levels.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:34:27 +08:00
Bohan Jiang
b7082a01f1 fix(issues): retry button targets the row's agent (MUL-2457) (#2921)
* fix(issues): retry button targets the row's agent, not the assignee (MUL-2457)

The execution log retry button used to re-fire the issue's current
assignee instead of the agent that actually ran the clicked row. After
a reassignment, or for squad workers / @-mention agents, the rerun
landed on the wrong agent.

POST /api/issues/{id}/rerun now accepts an optional task_id: when set,
the rerun targets that task's agent (and reuses its leader/worker
role). An empty body keeps the assignee-driven CLI/API contract.

The execution-log retry button passes task.id, so per-row retry always
fires the correct agent. enqueueMentionTask gained a forceFreshSession
parameter so the new mention-path rerun keeps the same fresh-session
contract as the assignee path.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): inherit trigger provenance + fix cross-issue test (MUL-2457)

Address review feedback on PR #2921:

1. RerunIssue now inherits TriggerCommentID from the source task when
   sourceTaskID is valid. Without this, a per-row rerun of a comment-
   or mention-triggered task degrades into a generic issue run because
   the daemon's buildCommentPrompt path keys on TriggerCommentID. The
   inherited summary is rebuilt naturally inside the enqueue helpers
   (buildCommentTriggerSummary derives it from the comment ID).
2. The new cross-issue rejection test inserted a second issue without
   `number`, hitting uq_issue_workspace_number on a same-workspace
   collision with the fixture's issue. Both inserts now claim the next
   available per-workspace number (MAX(number)+1) — matching the
   pattern used by notification_listeners_test.

Added TestRerunIssueInheritsTriggerCommentFromSourceTask to lock the
trigger provenance contract.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:30:03 +08:00
Angular
314e91fa6d fix(chat): guard optimistic task message ids (#2901) 2026-05-20 15:18:42 +08:00
Bohan Jiang
68270e238e MUL-2339: polish(agent-inspector): optimistic updates + picker layout + thinking-default semantics (#2919)
* polish(agent-inspector): optimistic updates + picker layout + thinking-default semantics

Round of cleanup on the agent inspector pickers after using them end-to-end:

1. **Optimistic updates** (`agent-detail-page.tsx`)
   The `handleUpdate` callback that backs every inspector picker
   (thinking / model / visibility / concurrency / runtime / name /
   description / avatar) was strictly sequential:
   `await api.updateAgent → invalidateQueries → toast.success`. Each pick
   waited 0.5-2s for the network round trip before the trigger chip
   updated, which read as visible UI lag.
   Snapshot the cached agent list, patch the matching agent
   synchronously via `setQueryData`, then run the network request in
   the background. On error roll back to the snapshot before the toast
   surfaces the cause. All inspector pickers now respond instantly.

2. **Block-in-inline fix in Model + Thinking pickers**
   `PickerItem` wraps its children in a flex `<span>`. The picker
   bodies had `<div>` children, which is block-in-inline (invalid
   HTML5) and triggers a browser layout quirk that off-aligns
   descendants — model IDs floated to the center under their labels
   in ModelPicker, descriptions indented unevenly under levels in
   ThinkingPicker. Replace the inner `<div>`s with `<span block
   text-left>` so the layout is deterministic across rows.

3. **Visual polish in Thinking picker**
   Label was `font-medium` at the parent's default `text-sm` (14px),
   chunky next to the 10px description. Drop to `text-[13px]`, bump
   description to `text-[11px] leading-snug` with `mt-0.5` so the
   contrast between rows feels less jarring.

4. **Match Model picker's row typography to Thinking's**
   Same `text-[13px]` for label + `text-[10px] mt-0.5` for the model
   ID. Both pickers now read as the same component family.

5. **"Default" semantics: follow CLI config, not model factory default**
   The chip displayed "Default" / "default" badge when no
   `thinking_level` was set, alongside a `[default]` chip on the
   model's factory-advertised default option in the menu. That was
   misleading: when Multica omits `--effort` (because picker is
   unset), it's the user's *local CLI config* (claude/codex) that
   decides the reasoning level — not the model's factory default.
   Showing "medium [default]" while the user has xhigh in their CLI
   config lies about what actually fires at the API.
   - Trigger label: "Default" → "Follow CLI config" (zh: "跟随 CLI 配置")
   - Footer clear button: "Use model default" → "Follow CLI config"
   - Footer tooltip: explicitly mentions claude/codex CLI config
   - Inline `[default]` badge on the factory-default option: removed
   - `defaultLevel` prop chain (picker + prop-row + test): cleaned up
     as now-dead code

6. **Stop hiding the Thinking row while discovery loads**
   `if (levels.length === 0 && !value) return null` hid the row
   while the runtime-models query was still in flight, which
   subscribed-then-unsubscribed from useQuery in such a way that
   the discovery only fired when the user manually opened the Model
   picker. Gate the early return on `!isLoading && !isFetching` so
   ThinkingPropRow stays mounted (and thus its useQuery keeps
   subscribed) until discovery returns; row appears as soon as
   data arrives, no Model-picker tap required.

7. **Drop the inline tooltip on Thinking picker items**
   The same description was rendered both inline under the label
   (always visible) and as a hover tooltip (overlapping the next
   row). The hover bubble was redundant — removed.

Tests
- `pnpm --filter @multica/views test thinking-picker` → 7/7 pass after
  renaming the "Default" assertion + clearing the unused defaultLevel
  test prop.
- `pnpm --filter @multica/views typecheck` clean.

* fix(test): align thinking-prop-row tests with renamed copy + loading-aware row gate

CI surfaced 3 broken assertions in `thinking-prop-row.test.tsx` —
all consequences of the polish PR's behaviour changes that the test
file hadn't tracked:

- "hides the row when ... no thinking levels and nothing is persisted"
  The row now stays mounted while runtime-models discovery is in
  flight (so the useQuery subscription actually survives long enough
  to issue the request — fixes the bug where Thinking only appeared
  after manually opening the Model picker). The assertion asserted
  absence only after `initiate` was called, but loading is still in
  progress at that point. Wrap the absence assertion in `waitFor`
  so it waits for the row to disappear after the query settles.

- "clears the orphan value via the picker footer"
  Tooltip copy changed from "Clear and fall back to this model's
  default reasoning level" → "Clear the override and let the local
  CLI config decide the reasoning level". Update the regex.

- "renders the row with \"Default\" when value is empty"
  Trigger label changed from "Default" → "Follow CLI config" to
  reflect that Multica omits --effort and the local CLI config
  decides. Update the assertion + test name.

`pnpm --filter @multica/views test` → 701/701 pass.

* fix(agent-inspector): drop loading-row gate + per-field optimistic rollback (MUL-2339)

Addressing review feedback on #2919:

- ThinkingPropRow no longer keeps the row visible during discovery.
  The previous explanation ("early return null aborts the useQuery
  subscription") was wrong — React doesn't unmount a component that
  returns null, so hooks (and their subscriptions) stay live. The
  loading-aware gate only succeeded in showing an empty "Follow CLI
  config" row that opened to an empty menu before discovery settled.
  Restore the simple `levels empty && !value -> null` behavior; the
  sibling ModelPicker mounts unconditionally and keeps the shared
  runtime-models query active regardless.

- AgentDetailPage.handleUpdate now rolls back only the fields the
  failing PATCH wrote, instead of restoring a whole-list snapshot.
  A whole-list snapshot rollback discards any concurrent successful
  inspector mutation that landed between snapshot and rollback. Per-
  field rollback + a final invalidate converges the cache on server
  truth without clobbering unrelated optimistic writes.

- Sync the now-stale "use model/runtime default" wording in the
  thinking-related JSDoc and type comments: empty thinking_level is a
  "no override" sentinel — the backend omits --effort and the upstream
  CLI config decides — not a Multica-known default level.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:18:34 +08:00
Bohan Jiang
eaf8b14866 fix(installer): post-merge nits from #2881 (MUL-2458) (#2922)
- Capture `brew tap` output and print the same diagnostic tail on
  failure that `brew install` already prints, so #2867-style "no
  signal" reports are gone from both Homebrew failure paths.
- Add a `brew tap` failure regression case to `scripts/install.test.sh`
  and refactor the test runner to share sandbox/curl-stub setup; both
  cases now also assert the diagnostic tail is emitted.
- Move the shell installer test out of the heavy backend job into a
  dedicated `installer` matrix job that runs on `ubuntu-latest` and
  `macos-latest`, since the installer targets macOS/Homebrew and BSD vs
  GNU `tar` / `sed` / `mktemp` differences are the next likely break.
- Surface `MULTICA_INSTALL_DIR`, `MULTICA_BIN_DIR`, and
  `MULTICA_SELFHOST_REF` in `install.sh --help` so `MULTICA_BIN_DIR`
  stops looking like a test-only knob.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:18:17 +08:00
Jiayuan Zhang
41753d17a2 feat(desktop): pin tab (MUL-2449) (#2914)
* feat(desktop): pin tab — keep parked tabs anchored across navigations (MUL-2449)

Adds tab pinning to the desktop tab bar. Pinned tabs render as icon-only at
the left, suppress the X close button, and intercept any `navigation.push()`
that would change their pathname — those are redirected into a new tab so
the pinned tab stays parked on its original route. Search/hash/back/forward
stay in-tab so pinned filter and drawer state still work.

Implements the FINAL combo from the MUL-2449 RFC §4: right-click menu +
⌘⇧P shortcut (D1 a+c), icon-only visual (D1v i), pathname-change → new tab
with same-path-allowed (D2a/b A), back / refresh allowed (D2c/d A), pinned
auto-cluster left and persist (D3a/b A), pinned can't be X-closed (D3c A),
dedupe respected (D4a A), default Issues tab pinnable (D4b A), drag clamped
to its zone (D4c A), deep link prefers pinned (D4e A).

Store changes:
  - Tab.pinned added; togglePin maintains the "pinned first" invariant by
    inserting at the zone boundary.
  - moveTab clamps cross-zone drags so dnd-kit can't violate the ordering.
  - Persistence bumped v2 → v3 with a defaulting migration (pinned=false).
    Rehydrate sorts pinned-first as a defensive net.

Navigation:
  - tryRouteToPinnedNewTab compares the active tab router's live pathname
    to the target. Same-pathname push (query / hash / sub-router) falls
    through to the router; different pathname → openTab + setActiveTab
    (foreground; respects dedupe).

UI:
  - Tab bar wraps each tab in a shadcn ContextMenu with Pin/Unpin + Close
    (Close disabled for pinned or last-remaining tab).
  - Pinned tabs use a narrower icon-only layout with an accent left border
    and a divider between the pinned and unpinned groups.
  - Global keydown listener registers ⌘⇧P / Ctrl+Shift+P to toggle pin on
    the active tab.

Tests: - tab-store: togglePin ordering, moveTab boundary clamping, v2→v3
    migration.
  - navigation: pinned push → new foreground tab; same-pathname push stays
    in tab; cross-workspace still wins over pin.
Co-authored-by: multica-agent <github@multica.ai>

* test(desktop): cover TabNavigationProvider.push pin interception (MUL-2449)

Add pathname-diff / same-pathname cases for the per-tab navigation
adapter. Existing tests only exercised the root-level
DesktopNavigationProvider, but in-tab AppLink / page clicks flow
through TabNavigationProvider — so a future refactor that drops the
pin check from that provider would silently regress.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(desktop): pin tab — hover button, full title, drop ⌘⇧P (MUL-2449)

Jiayuan's interactive review of PR #2914 surfaced three changes to the
RFC's D1 (entry / visual) decisions:

  1. Drop the ⌘⇧P global shortcut — it added a keybinding for a
     low-frequency action and crowded the shortcut namespace.
  2. Reveal a Pin / Unpin button on tab hover instead of relying on the
     right-click menu as the primary entry; right-click remains as a
     fallback (and for Close).
  3. Pinned tabs keep their full title and width. The only weak visual
     differences vs. unpinned tabs are the accent left border and the
     suppressed X close button.

Removes the global keydown listener (no other doc / handler referenced
it). Adds a hover-only Pin / Unpin span next to the existing close
affordance, both gated by group-hover. Drops the icon-only width /
hidden-title styling for pinned tabs.

Tests: new tab-bar.test.tsx covers Pin / Unpin button rendering, click
handlers (togglePin), the hidden-X invariant on pinned tabs, and the
full-title rendering. 146 passed, typecheck clean.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(desktop): pin tab — drop accent left border, swap leading icon to Pin (MUL-2449)

Jiayuan reported that the accent left border on pinned tabs reads as a
heavy black edge in light mode and looks unrefined. Replace it with a
quieter identifier: pinned tabs swap their route icon for a Pin glyph
in the leading slot (same size, no extra horizontal space). The hidden
X close button stays as the secondary cue. RFC §3 D1v moves from
iii FINAL to iv FINAL; iii is demoted to v2 FINAL → v3 REMOVED.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 09:14:43 +02:00
Angular
edded77691 fix(installer): fall back when brew install fails (#2881) 2026-05-20 15:14:18 +08:00
Naiyuan Qing
6166a0b6a6 refactor(mobile): swap SegmentedControl for RNR Tabs; drop bg-popover from sheet contents
- Add components/ui/tabs.tsx (RNR Tabs primitive wrapper on
  @rn-primitives/tabs, shadcn-style API).
- My Issues and the More > Issues page swap iOS SegmentedControl for
  the new RNR Tabs — consistent visual with the rest of the RNR
  components and gives count-suffix labels room to breathe.
- Switch the shared SHEET_OPTIONS contentStyle from height: "100%" to
  flex: 1 — works for both fixed-detent and fitToContents sheets,
  whereas the explicit 100% height pre-empted flex behaviour in the
  fitToContents case.
- Drop the explicit `bg-popover` background from sheet root Views
  (chat-sessions, issues-filter, runs, comment actions/emoji-picker,
  add-resource). The iOS formSheet container already paints the
  popover surface; an inner bg-popover stacked on top showed as a
  subtle double-layer when detents animated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:04:00 +08:00
Naiyuan Qing
fe62f7f5b2 feat(mobile): More tab opens dropdown popover anchored above the tab
Tapping the More tab now opens a small DropdownMenu popover containing
the user card, workspace switcher, and secondary nav (Issues/Projects)
— anchored directly above the tab button. Replaces the previous
listeners.tabPress that pushed /menu as an iOS formSheet, which felt
heavy for a quick switch.

Implementation:

- Add @rn-primitives/dropdown-menu and a shadcn-style wrapper at
  components/ui/dropdown-menu.tsx (Root/Trigger/Portal/Overlay/Content/
  Item/Label/Separator using semantic tokens — bg-popover, accent,
  border — matching the existing button.tsx pattern).
- New MoreTabDropdownAnchor (components/nav/more-tab-dropdown.tsx)
  mounts as a sibling to <Tabs> at the workspace tabs layout. It is
  absolute-positioned over the More tab's screen rect (right 25%,
  bottom = safe-area inset, height = 49) with pointerEvents="box-none"
  so taps pass straight through to the real tab button. The Trigger
  inside is an invisible Pressable; opened imperatively via
  TriggerRef.open() from listeners.tabPress on the More tab. The
  @rn-primitives Trigger measures its own rect inside open(), so the
  popover anchors correctly without manual screen-width math.
- The /menu formSheet route stays registered in [workspace]/_layout.tsx
  as a dead path for now (reversibility); to be removed once the
  popover bakes in.

Rejected alternative: replacing the More tab's tabBarButton with a
custom DropdownMenuTrigger wrapper. RN's BottomTabItem wraps the
returned button in <View style={{flex:1}}> and expects a single
Pressable; introducing the DropdownMenu Root as an extra wrapping View
broke the flex layout and stripped the "More" label. The Option B
pattern here leaves the real tab button entirely untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:03:48 +08:00
Naiyuan Qing
e6c296deb3 refactor(mobile): drop project board (kanban) view, keep list-only
Mobile intentionally diverges from web's Board / List view selector and
ships only the status-grouped list. Reasons (now documented in the file
docblock):

- Phone screens are too narrow to show ≥3 status columns at once,
  defeating kanban's core "see pipeline at a glance" value — users
  end up swiping between near-empty columns.
- Major mobile task apps (Linear iOS, Things, Apple Reminders) don't
  ship kanban; list with status grouping is the established
  small-screen pattern.
- mobile/CLAUDE.md "Behavioral parity" permits UI divergence when
  semantics agree. Same issues, same status enum, same 6
  BOARD_STATUSES grouping — only the layout differs.

What stays from the prior plan:
- Full BOARD_STATUSES grouping (backlog / todo / in_progress /
  in_review / done / blocked) — the real parity fix replacing the
  earlier mobile-only "Open / Done" two-bucket rollup. Cancelled
  remains hidden on both clients.

What's removed:
- BoardView component + horizontal ScrollView
- View mode SegmentedControl + ViewMode local state
- BoardView's column-empty placeholders

The `@react-native-segmented-control/segmented-control` dependency is
kept — my-issues and more/issues still use it for scope tabs (Mine /
All / Agents) where semantics also vary on web.
2026-05-20 14:05:46 +08:00
Naiyuan Qing
e6f175e752 feat(mobile): project surface v1 — Board view, hex/SVG sweep, planning docs
Closes the remaining items from project-v1-plan.md:

- View mode switcher (List / Board) on project detail's related-issues:
  - List mode regrouped into full BOARD_STATUSES (backlog / todo /
    in_progress / in_review / done / blocked), replacing the mobile-only
    "Open / Done" two-bucket rollup that silently diverged from web's
    six-bucket grouping (parity violation, gap audit §3)
  - Board mode: horizontal scroll, one status column per group, each
    column is a FlatList of IssueRow (reuses existing primitive)
  - View mode is local useState — no Zustand store (single component
    scope, mobile/CLAUDE.md "no state unless required")

- Hex sweep → THEME tokens / NativeWind semantic classes (gap audit §5):
  project-properties-section, project-resources-section, project/[id],
  more/projects. Eliminates the last project-domain dark-mode breakage.

- Hand-drawn SVG icons → existing primitives (gap audit §6):
  more/projects PlusButton → <IconButton name="add">
  project-properties-section chevron → <Ionicons name="chevron-forward">
  project-related-issues chevron → <Ionicons name="chevron-forward">
  Drops react-native-svg where no longer used.

Items 1 / 2 / 4 (Tier B picker migration, progress section, new-project
draft persistence) landed in preceding commits c644e2a3, 7337206f,
2ff95c34. With this PR the full project-v1-plan is implemented and the
two planning docs (gap audit + implementation plan) are committed for
future reference.
2026-05-20 13:51:17 +08:00
Bohan Jiang
9d3b6e2241 feat(agent): inspector picker for thinking_level (MUL-2339) (#2912)
* feat(agent): inspector picker for thinking_level (MUL-2339)

PR1 (#2865) shipped the backend — column, daemon-side discovery,
Claude/Codex injection, API validation — but the agent detail inspector
had no UI to set the value. Users could only configure thinking_level
via custom_env / API. This wires up the picker so it lives next to
Runtime and Model where everything else editable already lives.

Picker is per-(runtime, model): it reuses the same `runtimeModelsOptions`
query the Model picker already runs (60s cache, no extra round-trip)
and reads the active model's `thinking.supported_levels`. When the list
is empty — every provider except Claude/Codex today, or a Claude model
that doesn't expose `--effort` — the entire PropRow is hidden, not just
rendered inert. The picker never gets to invent value/label pairs
itself; they come verbatim from each CLI's own catalog (`Low`,
`Extra high`, …) so the user sees exactly what `claude --effort` /
`/effort` and Codex's TUI show.

The `default_level` from the catalog is badged inside the popover so
the user knows which value `""` (the persisted "use model default"
sentinel) maps to. The clear footer sends `""` explicitly, which the
backend already understands as the tri-state "explicit clear" branch
of UpdateAgent. Invalid combinations (e.g. picking a value not in the
target provider's enum after a runtime swap in the same PATCH) hit
the existing 400 path on the server and surface as a toast via the
inspector's standard `onUpdate` error handler — no extra client-side
guard needed.

Exports `RuntimeModelThinking` and `RuntimeModelThinkingLevel` from
`@multica/core/types` so views consumers can refer to them by name.
i18n keys added in EN and zh-Hans (parity test green).

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): preserve unknown thinking_level in picker label

Stale persisted values (model swap, CLI catalog shrink) used to render
as 'Default' even though the backend would still ship the orphaned
token. Fall back to the raw value when no entry matches so the user
sees what's actually saved and can clear it.

Co-authored-by: multica-agent <github@multica.ai>

* test(agent): unit tests for thinking-picker label + clear flow

Covers the default-vs-set trigger label, the unknown-token preservation
path added in 3452fae3f, the read-only display, picking and re-picking
into onChange, and the clear footer's empty-string emission.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): keep Thinking row visible when value is stale (MUL-2339)

Inspector was hiding the row whenever the active model had no
supported_levels, which also hid persisted orphan tokens (model swap
into a non-thinking runtime, or a CLI catalog that shrank). PR1's
per-model invalid behavior is daemon-side warn/drop, not a synchronous
DB clear, so the frontend has to surface the raw value and let the
user explicit-clear it via the picker footer.

Render the row when levels are empty AND value is empty; otherwise
keep it. Extract ThinkingPropRow into its own file so the row-level
logic is unit-testable.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 13:47:19 +08:00
Naiyuan Qing
2ff95c3413 feat(mobile): mirror web project progress section in header card
Adds a horizontal progress bar driven by `done_count / issue_count`
plus a "X / Y · NN%" label, hidden when issue_count is zero (no info
to show + divide-by-zero hazard). Mirrors web's project-detail.tsx
596-620 to satisfy behavioral parity — web users see project progress
in the project header, mobile users should too.

Note: this change was added autonomously by the code-review follow-up
agent outside the original 6-item review scope. Code quality is sound
(token-based colors, zero-count guard, web source referenced inline)
so kept rather than dropped, but flagged here for traceability.
2026-05-20 13:37:07 +08:00
Bohan Jiang
2bec2221d2 feat(agent): per-agent thinking_level for claude + codex (MUL-2339) (#2865)
* feat(agent): persist thinking_level per agent (MUL-2339)

Adds a nullable `thinking_level` column to the `agent` table so the
backend can route a runtime-native reasoning/effort token (e.g. Claude's
`xhigh`, Codex's `minimal`) through to the agent CLI on every dispatch.

The column is intentionally TEXT rather than an enum — Claude and Codex
publish overlapping but distinct vocabularies and we want the persisted
value to round-trip exactly through whichever CLI receives it. NULL is
the "use runtime default" sentinel that every downstream consumer reads
as "do not inject --effort / reasoning_effort".

This commit is just the storage layer (migration + sqlc); subsequent
commits wire it through the API, daemon, and agent backends.

Co-authored-by: multica-agent <github@multica.ai>

* feat(agent-backend): inject reasoning effort for claude + codex (MUL-2339)

Extends ExecOptions with a runtime-native ThinkingLevel string and wires
it into the Claude and Codex backends. Discovery is driven by the local
CLI so the daemon advertises whatever the host install supports rather
than a hand-maintained list that goes stale.

Per Elon's PR1 review:
- Claude: parses `claude --help` to learn the `--effort` superset and
  projects through a per-model allow-list (xhigh is Opus-only; max is
  session-only on the smaller models). Falls back to a conservative
  static list when the binary is missing or help drift hides the line.
- Codex: drives `codex debug models --output json` so per-model
  reasoning subsets and the documented default come directly from the
  CLI. The older config-error probe trick is gone — the JSON path is
  stable and doesn't pollute stderr with an intentional misconfig.
- Cache key includes (provider, executablePath, cliVersion) so a CLI
  upgrade invalidates entries that referenced the older help / catalog.

Per Trump's PR1 constraint, all three Codex injection points
(thread/start.config, thread/resume.config, turn/start.effort) flow
through one helper (`applyCodexReasoningEffort`) so they cannot drift
independently. The shared `codexReasoningCases` fixture in
`thinking_test.go` asserts the same value→{shape, key} contract at
each site for every level the runtimes know about.

Claude's `--effort` is also added to `claudeBlockedArgs` so a user
custom_args entry can't silently outvote the daemon-injected value.

Co-authored-by: multica-agent <github@multica.ai>

* feat(api): wire thinking_level through API + daemon contract (MUL-2339)

End-to-end plumbing for the per-agent reasoning/effort setting:

- AgentResponse / TaskAgentData now carry `thinking_level`; the daemon's
  claim response includes it and the daemon's executor passes it through
  to agent.ExecOptions, where the Claude and Codex backends already know
  what to do with it.
- ModelEntry on the runtime-models wire format gains a `thinking` block
  carrying `supported_levels` + `default_level` per model so the UI can
  render a runtime-aware picker without the server having to know about
  the local CLI install. `handleModelList` projects the agent-package
  catalog (including the new Thinking field) into the wire shape.
- CreateAgent / UpdateAgent gate the field with a synchronous provider
  enum check (claude / codex only today). UpdateAgent is tri-state:
  field omitted = no change, "" = explicit clear (new
  `ClearAgentThinkingLevel` query, mirrors the existing mcp_config null
  pattern), non-empty = validate then set.

Per Trump's PR1 review, the API NEVER auto-clears on a runtime/model
swap and ALWAYS returns 400 on an unknown literal value — same shape
across CreateAgent, UpdateAgent, and combined patches that move
runtime + level in one request. Per-model combination failures (e.g.
`xhigh` against a model that only supports up to `high`) surface as a
daemon-side task error, not a silent server-side rewrite.

TS types follow the same shape: `Agent.thinking_level`,
`CreateAgentRequest`/`UpdateAgentRequest` add the field, `RuntimeModel`
grows a `thinking` block. Older backends omit the field, which the
front-end treats as "no picker for this model" — installed desktop
builds keep working.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): correct codex debug models argv + pin via runner test (MUL-2339)

`codex debug models --output json` is rejected by codex-cli 0.131.0 —
the subcommand emits JSON on stdout by default and has no `--output`
flag. Drop the flag and add `--bundled` to skip the network refresh
discovery doesn't need. Move the argv to a package-level var and add
a test that runs a fake `codex` to assert the binary actually
receives exactly `debug models --bundled`, so the contract can't
silently drift on the next refactor.

Also teach ValidateThinkingLevel to resolve an empty model to the
provider's default model entry. Without this, every default-model
task with a persisted thinking_level would be misjudged "unknown
model" by the daemon guard.

Co-authored-by: multica-agent <github@multica.ai>

* fix(api): reject runtime switch that would leave invalid thinking_level (MUL-2339)

A PATCH that changed `runtime_id` without touching `thinking_level`
used to silently keep the existing value, so a Claude agent storing
`max` could land on a Codex runtime where `max` is not a recognised
token at all, and the daemon would receive a literal-invalid level.

Hold the same "always 400 on literal-invalid, never silent coerce"
rule on this implicit path. When runtime_id changes and the existing
value is not in the new provider's enum, return 400 with the
recovery options (clear via `thinking_level=""` or re-set in the
same PATCH).

Add coverage for both the kept-when-still-valid and the rejected
cases, plus the two recovery paths (clear and replace).

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): guard runTask with per-model thinking_level validator (MUL-2339)

ValidateThinkingLevel existed but had no call site — `task.Agent.
ThinkingLevel` flowed straight into ExecOptions, so `xhigh` configured
on a non-Opus Claude model, or API-side stale values that escaped the
provider enum gate, would be injected anyway.

Run the validator before building ExecOptions. Invalid combinations
log a warning and drop the level instead of failing the task: the
agent still runs, just at the runtime's default reasoning effort.
Discovery errors fail open (keep the level, let the CLI surface any
objection) so a transient `claude --help` failure can't strand work.

Empty model is forwarded as-is; the validator resolves it to the
provider's default model internally per the cross-package contract.

Co-authored-by: multica-agent <github@multica.ai>

* chore(agent): drop stale `--output json` comments + unused scanner (MUL-2339)

Codex CLI's `debug models` subcommand emits JSON without an `--output`
flag, and `parseCodexDebugModels` never read from the bufio.Scanner.
Sync the comments with the actual invocation and remove the dead init.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:30:10 +08:00
Jiayuan Zhang
292226f632 fix(runtimes): use official Gemini spark icon (MUL-2447) (#2904)
* fix(runtimes): use official Gemini spark icon (MUL-2447)

Gemini provider was falling through to the default Monitor icon in the
runtime list. Add the official 4-point spark mark with Google's
blue → purple → pink gradient, matching the SVG style/sizing of the
other provider icons.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): use current Gemini multicolor spark gradient (MUL-2447)

Per review on PR #2904: the previous 3-stop blue/purple/pink gradient
was the legacy Bard-era Gemini spark. Update to the 5-stop cyan → blue
→ purple → pink → orange gradient used by the current Gemini app/web
multicolor mark.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): switch Gemini icon to aurora multicolor treatment (MUL-2447)

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): align Gemini aurora color positions and smooth spark path

Swap yellow/green radial gradient anchors so colors land at the official
positions: top red / right blue / left yellow / bottom green, matching
gemini.google.com's current aurora spark. Replace the arc-based 4-point
spark outline with a cubic-bezier version normalized to the 24-viewBox
so the inset between tips is smoother and closer to the gstatic source.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): use Simple Icons Google Gemini mark (MUL-2447)

Drop the hand-crafted aurora gradient approximation and inline the
canonical "Google Gemini" path from Simple Icons (CC0 1.0), rendered
in the Simple Icons brand color (#8E75B2). This matches the pattern
used by the other provider marks in this file (Claude/Codex from
Bootstrap Icons, etc.) instead of trying to manually approximate the
official multicolor wash from gemini.google.com (which paints via a
clipPath over an embedded raster).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:27:53 +08:00
Jiayuan Zhang
72339f347b fix(desktop): keep local machine row visible after stopping daemon (#2906)
The Start button lives in `DaemonRuntimeActions`, which is rendered in
the per-machine detail pane and only when the selected machine is
flagged `isCurrent`. After the user manually stopped the daemon,
`status.daemonId` went back to undefined, so no machine could be
matched as `isCurrent` — the local row either disappeared (when the
server-side runtime had been GC'd) or moved into the "remote" section
(when it was still present but unmatched). Either way the Start button
was unreachable until the app was restarted.

Two-part fix:

- `DesktopRuntimesPage` now caches the last-known daemonId/deviceName
  so the local match keeps working while the runtime is still on the
  server (recently_lost / offline window).
- `buildRuntimeMachines` accepts an `ensureLocalMachine` flag; when no
  real runtime matches, a placeholder local row is synthesized so the
  Start button still has a home. Desktop opts in via a new
  `hasLocalMachine` prop on `RuntimesPage`. The empty state is also
  suppressed when this prop is set so the placeholder row isn't hidden
  behind the "register a runtime" hint on first launch.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 06:16:20 +02:00
Naiyuan Qing
2cb2016235 fix(mobile): align picker search box to title (px-4)
The three search-bearing picker bodies (assignee / label / project) had
title rows at px-4 and search boxes at px-3 — a 4px misalignment where
the search field's leading edge sat outside the title's leading edge.
Bring the search container to px-4 so the title text, the search
placeholder, and the search input all share one vertical baseline.

Status / priority / due-date pickers have no search box (and so no
misalignment); project-detail lead picker has no title row (search box
defines its own px-3 baseline), both intentionally unchanged.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
892df00b54 fix(mobile): menu sheet keeps fitToContents detent
The Tier B sheet migration swept menu.tsx into shared SHEET_OPTIONS,
which set sheetAllowedDetents=[0.6, 0.95]. That's right for picker-row
sheets where consistency across neighbour chips matters, but the menu
is an isolated sheet (≤ 5 fixed actions, opened from the tab bar) —
the two-snap default leaves ~60% of the sheet blank.

Override sheetAllowedDetents to "fitToContents" for menu only, and
amend the SHEET_OPTIONS rationale in apps/mobile/CLAUDE.md so the rule
is spelled out: picker-row sheets share the explicit detents for
muscle-memory carry-over; isolated sheets shrink-wrap.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
2a6b4bf641 fix(mobile): make first mount a true no-op in draft-store reset hooks
The two cross-route draft store reset hooks (new-issue, chat-session)
documented their first mount as "effectively a no-op" but the
implementations stomped the store on every workspace-id transition
including the initial null → uuid resolve. That's harmless when the
store is already INITIAL but contradicts the docblock and would corrupt
any future code that pre-seeds the store before navigation lands.

Gate the reset() call on a useRef-tracked previous id so it only fires
on genuine transitions. Matches the new-project-draft-store hook added
in the prior commit so all three stores follow one shape.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
7337206f9e refactor(mobile): new-project draft store + formSheet pickers
Replaces the one-off DraftPickerModal (RN <Modal transparent fade> +
centered card) in project/new.tsx with the same cross-route draft-store +
formSheet picker route pattern as new-issue. Status / priority chips now
push /new-project-picker/<field> like the new-issue chips do, and the
picker bodies are reused as-is.

Removes the last hand-rolled modal sheet introduced after the Lesson 6
formSheet migration — keeping the rule "every sheet is a formSheet route"
intact across the codebase.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
d77d13f23b fix(mobile): cold-start anchor for formSheet deep links
Without unstable_settings.anchor, a deep link or notification that
targets a formSheet route (issue/[id]/picker/status, etc.) cold-starts
the app onto the sheet alone — no parent screen, swipe-down lands the
user on a blank canvas. Anchor: '(tabs)' tells Expo Router to mount the
tab UI as the implicit base, so dismissing the sheet always returns to
a sensible workspace home.

Set on the workspace _layout.tsx that owns every formSheet route
registration. The root (app)/_layout has no formSheet declarations so
no anchor is needed there.
2026-05-20 12:15:20 +08:00
Jiayuan Zhang
fc8528d64d feat(autopilot): support assigning to a squad (MUL-2429) (#2888)
* feat(autopilot): support assigning autopilot to a squad (MUL-2429)

Path A (Squad-as-Leader) from the RFC: when an autopilot's assignee is a
squad, dispatch resolves to squad.leader_id and executes against the
leader's runtime — semantics match a human manually assigning the issue
to that squad, no fan-out.

Backend scope only; frontend picker change is a follow-up PR.

Changes:
- 096_autopilot_squad_assignee migration: drop agent FK on
  autopilot.assignee_id, add assignee_type column (default 'agent'),
  add autopilot_run.squad_id attribution column.
- service.AgentReadiness: single source of truth for archived /
  runtime-bound / runtime-online checks. Shared by autopilot
  admission gate, run_only dispatch, and isSquadLeaderReady.
- service.resolveAutopilotLeader: translates assignee_type/id to the
  agent that actually runs the work.
- dispatchCreateIssue: stamps issue with assignee_type='squad' for
  squad autopilots and enqueues via EnqueueTaskForSquadLeader.
- dispatchRunOnly: belt-and-braces readiness re-check after resolving
  squad → leader so a leader that went offline between admission and
  dispatch produces a clean failure instead of a doomed task.
- handler.CreateAutopilot / UpdateAutopilot: accept assignee_type with
  squad/agent existence + leader-archived validation. Backward-compatible
  default of "agent" preserves the contract for older clients.
- Analytics: AutopilotRunStarted/Completed/Failed events carry
  assignee_type and squad_id; PostHog can now group autopilot runs by
  squad without joining back to the autopilot row.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): reject archived squads, route post-admission skips, cleanup dangling-agent autopilots (MUL-2429)

Addresses three review findings on PR #2888:

1. Archived squad handling: validateAutopilotAssignee now rejects squads
   with archived_at set; resolveAutopilotLeader returns errSquadArchived
   so the admission gate fails closed; DeleteSquad now mirrors the issue
   transfer for autopilot rows (TransferSquadAutopilotsToLeader) so
   surviving autopilots flip to assignee_type='agent' (leader) instead
   of dangling at the archived squad.

2. dispatchRunOnly post-admission readiness: introduces errDispatchSkipped
   sentinel, recognised by DispatchAutopilot via handleDispatchSkip so
   the run is recorded as `skipped` (not `failed`). Manual triggers no
   longer 500 when the leader's runtime goes offline between admission
   and task creation. New TestManualTriggerDoesNotErrorOnPostAdmissionSkip
   locks the behaviour in.

3. Dangling agent assignee after migration 096 dropped the FK:
   shouldSkipDispatch now distinguishes pgx.ErrNoRows / errSquadArchived
   (hard skip — retrying won't help) from transient DB errors
   (fail-open). DeleteAgentRuntime pauses autopilots that target agents
   about to be hard-deleted (ListArchivedAgentIDsByRuntime +
   PauseAutopilotsByAgentAssignees) so the breakage surfaces as a paused
   row in the UI instead of a quiet skip-burning loop.

Unit tests cover the sentinel unwrap contract and errSquadArchived
errors.Is behaviour. Integration test
TestAutopilotDispatchSkipsWhenRuntimeOffline re-verified against a fresh
DB with migration 096 applied.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): bump last_run_at on post-admission skip (MUL-2429)

Match recordSkippedRun (pre-flight skip) and the success path so the
scheduler / "last seen" UI both reflect that this tick evaluated the
trigger, even when the post-admission readiness gate caught a late
regression.

Addresses Emacs review caveat #1 on PR #2888.

Co-authored-by: multica-agent <github@multica.ai>

* feat(autopilot): mixed agent/squad assignee picker in dialog (MUL-2429)

End-to-end UI for assigning an autopilot to a squad. Closes the PR #2888
backend gap: the squad-as-assignee feature was already wired in Go (Path A,
RFC §4) but the desktop dialog never offered the choice.

- core/types/autopilot: add `AutopilotAssigneeType`, surface
  `assignee_type` on `Autopilot` + Create/Update request payloads.
- views/autopilots/pickers/agent-picker: switch to a polymorphic
  AssigneeSelection (`{type, id}`); render agents and squads as two
  grouped sections with shared pinyin search.
- views/autopilots/autopilot-dialog: maintain `assigneeType` state, send
  it on create/update, render the trigger avatar / hover dot with
  `assignee.type`.
- views/autopilots/autopilots-page + autopilot-detail-page: render the
  assignee row using `autopilot.assignee_type` so squad-typed autopilots
  show the squad avatar + name, not a broken agent lookup.
- locales: add `agents_group` / `squads_group` / `select_assignee` keys
  (en + zh-Hans), keep legacy `select_agent` for callers that still
  reference it.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 05:30:13 +02:00
Naiyuan Qing
ef8f24c095 fix(mobile): typed picker pathname maps replace 'as never' router.push
attribute-row.tsx and create-form-attribute-row.tsx built the formSheet
route pathname via template strings cast 'as never', which silently
accepted any field name. Typos would compile and only blow up at runtime
with a 'no matching route' that's easy to miss in dev.

Introduce per-row IssuePickerField / NewIssuePickerField union types
mapped to literal-typed pathname records (with 'satisfies' to keep the
record exhaustive). Any new picker field is now a compile error until
both the union and the map are updated together.

Verified: changing 'priority' to 'pirority' at a call site now produces
TS2345 instead of compiling silently.
2026-05-20 11:27:06 +08:00
Naiyuan Qing
e5ee63aa1d fix(mobile): reset cross-route draft stores on workspace change
Both useNewIssueDraftStore and useChatSessionPickerStore hold
workspace-scoped state (assignee ids, draft session ids) that points at
records in the workspace that seeded them. Switching workspaces left
that state in place — a draft assignee from workspace A would survive
into workspace B's new-issue modal, where the id resolves to nothing.

Add a reset() to chat-session-picker-store (new-issue-draft-store
already had one) and expose a use…ResetOnWorkspaceChange(wsId) hook from
each store file. Wire both hooks once from workspace _layout.tsx so the
reset fires on every transition between matched workspace ids.

Docblocks updated to record where the reset is wired (single source of
truth: workspace _layout.tsx).
2026-05-20 11:21:18 +08:00
Naiyuan Qing
ac4507bcdd refactor(mobile): menu sheet uses shared SHEET_OPTIONS
Drop the bespoke 'fitToContents' branch for menu.tsx. Every other
formSheet uses [0.6, 0.95] explicit detents to dodge the iOS 26 +
Expo 55 fitToContents bugs (expo/expo#42904, #42965). Keeping menu on
the unsafe API solely because it 'shipped first' was a divergence
without a current reason — the bugs apply to it too. SHEET_OPTIONS is
now the single source of truth for every sheet.

CLAUDE.md Lesson 6 rationale updated to match.
2026-05-20 11:17:15 +08:00
Naiyuan Qing
c644e2a338 feat(mobile): project status + priority pickers via formSheet routes
Project detail's Status and Priority chips were the last two picker
chips still using the legacy centered-Modal pattern. The mixed gesture
(Status/Priority popped a centered card; Lead / Add Resource slid up a
formSheet) violated the picker-row consistency rule in CLAUDE.md
Lesson 6 — the four chips on the same row now all open the same way.

- New picker bodies under components/project/pickers/.
- New formSheet routes under app/(app)/[workspace]/project/[id]/picker/.
- Register both screens in workspace _layout.tsx using SHEET_OPTIONS.
- project/[id].tsx: drop the local state, swap chip onPress to
  router.push, and remove the trailing 'still uses transparent-Modal'
  apology comment.
- project/new.tsx is a draft modal so it can't push to a route (no
  project exists yet to read from cache). Inline a tiny DraftPickerModal
  shell that hosts the same picker bodies — documented in the file.
- Delete the obsolete ProjectStatusPickerSheet / ProjectPriorityPickerSheet
  files and update rnr-migration.md to reflect that B.2 is closed.
2026-05-20 11:15:25 +08:00
Naiyuan Qing
414c3b74a9 feat(mobile): full emoji picker for comment reactions via formSheet route
Mobile now offers the full emoji set behind a 'More reactions' overflow
in the per-comment actions sheet, matching web's emoji-mart parity.

- Adopt rn-emoji-keyboard 1.7.0 (zero runtime deps, React 19 / RN 0.83
  compatible, installed via expo install).
- New formSheet route at issue/[id]/comment/[commentId]/emoji-picker.tsx
  embeds EmojiKeyboard inline so UISheetPresentationController retains
  grabber, detents, and drag-to-dismiss.
- Quick-row overflow '+' button in comment actions pushes the new route.
- Delete the dead emoji-picker-sheet.tsx and the unused
  emojiPickerOpen state in comment-card.tsx (never opened from
  anywhere after the actions-route migration).
- Move QUICK_EMOJIS to lib/quick-emojis.ts since its old host file is
  gone.
- Update rnr-migration.md B.4 to record the resolution.
2026-05-20 11:07:34 +08:00
Naiyuan Qing
4ebdb69e7e fix(mobile): add inline titles to 5 issue picker bodies
SHEET_OPTIONS sets headerShown: false so every formSheet body must draw
its own title. Five issue pickers (status / priority / assignee / label /
project) were shipping headerless; only due-date had a title. Inline a
single header row in each body — five callers, no shared primitive (3x
rule not triggered).
2026-05-20 10:59:42 +08:00
Naiyuan Qing
383dd23c36 docs(mobile): markdown rendering ADR + selectable carve-out
Formalises the rendering decision (Path B — react-native-markdown-display +
Shiki + custom renderers) into a one-page ADR with A-tier source citations,
keeping the longer research log alongside it.

Adds a `selectable` opt-out to `CodeBlock` and `Markdown` so timeline
comments can disable RN's UIKit selection magnifier when an outer Pressable
already owns the long-press gesture, while issue descriptions and chat
messages keep the default selectable behaviour for copy-to-clipboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:50:12 +08:00
Naiyuan Qing
cc61ae3a0a feat(mobile): migrate sheet modals to route-level pageSheet (Tier B rollout)
Replaces the legacy "Modal transparent fade + hand-drawn backdrop" sheet
shell with expo-router route-level pageSheet modals — the canonical
container for content sheets per mobile/CLAUDE.md Lesson 6 and the Tier B
section of docs/rnr-migration.md.

Sheets deleted (9): chat session-sheet, comment-action-sheet, issue-filter-sheet,
six issue pickers (assignee, due-date, label, priority, project, status),
runs-sheet, project add-resource-sheet, project-lead-picker-sheet, plus the
shared sheet-shell and runs-sheet-store that supported them.

Route-level modals added: /[workspace]/{chat-sessions, issues-filter,
new-issue-picker/*, issue/[id]/{runs, picker/*, comment/[commentId]/actions},
project/[id]/{add-resource, picker/lead}}. Each picker is split into a thin
route file + reusable *-picker-body.tsx so the same body composes inside
the new-issue draft form and the issue-detail attribute row.

Comment CRUD endpoints (update / delete / resolve / unresolve) + matching
optimistic mutations + CommentSchema added to support the new comment
actions route. Two new draft/picker stores carry session-scoped state for
the chat-session picker and the new-issue form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:49:51 +08:00
Naiyuan Qing
7f33ae7eec feat(mobile): align my-issues + Issues with web/desktop — squad parity, scope tabs, RNR UI
- my-issues "agents" scope now uses server-side involves_user_id (MUL-2397)
  covering squads the user is involved in; tab label "Agents and Squads"
  matches web my-issues.json:14
- workspace Issues gains all / members / agents scope tabs with per-scope
  counts (client-side assignee_type filter mirroring issues-page.tsx:90-94),
  scope persists across workspace switches
- both screens migrate to iOS-native SegmentedControl, IconButton + dot,
  Ionicons chip X, and a shared IssuesLoading skeleton — drops hardcoded
  #71717a and react-native-svg usage on these surfaces
- new useClearFiltersOnWorkspaceChange hook + IssuesLoading component
  shared across both surfaces (three-occurrence threshold respected)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:42:12 +08:00
Naiyuan Qing
53ee5be68a docs(mobile): hoist "existing pattern first" to Principle 1 in UI rules
So AI agents grep the codebase for an analogous component before reaching
for RNR add or hand-rolling — structural fix for the pre-migration legacy
(21 hand-written components, 18 sheets) that accumulated by treating each
new screen as a blank slate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:41:40 +08:00
Jiayuan Zhang
4a487adfeb feat(github): split canView / canManage in settings tab for read-only members (MUL-2413) (#2898)
Wires the frontend half of the read-only RFC. The Settings → GitHub tab
now always issues the installation list query for any workspace member
(the backend gates it via `RequireWorkspaceMember` after PR #2886) and
gets `can_manage` straight from the API response. The render matrix
covers the six cases the RFC calls out:

- configured + connected + admin   → Disconnect + (optional) Connected by
- configured + connected + member  → read-only "Connected to" + read_only_hint
- configured + not connected + admin   → Connect button + dev description
- configured + not connected + member  → contact_admin_to_connect hint
- not configured + admin               → operator banner + disabled Connect
- not configured + member              → contact_admin_to_connect hint

New i18n keys (en + zh-Hans): read_only_hint, connected_by, contact_admin_to_connect.
The unused github.manage_hint string is removed (its non-admin branch
now resolves to one of the two new hints depending on connection state).

GitHubInstallation gains an optional `connected_by` display name so the
UI can render the "Connected by {name}" line without further changes
once the backend exposes the field.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 04:19:28 +02:00
Jiayuan Zhang
e48f6a84d6 feat(github): expose read-only installation list to workspace members (MUL-2413) (#2886)
* feat(github): expose read-only installation list to workspace members (MUL-2413)

Relax `GET /api/workspaces/{id}/github/installations` from owner/admin-only
to any workspace member so the Settings → Integrations tab no longer renders
blank for non-admins (the original symptom of MUL-2413).

The handler now reads the caller's role from the workspace middleware:
- owner / admin keep the full row including the numeric `installation_id`
  (the connect / disconnect handle) and receive `can_manage: true`.
- every other role (member / guest) receives rows with `installation_id`
  omitted and `can_manage: false`, giving them visibility into "is GitHub
  wired up?" without the management handle.

`GET /github/connect` and `DELETE /github/installations/{id}` stay under
the admin/owner middleware group — this PR only relaxes the read path.

Tests: `TestListGitHubInstallations_RoleGating` exercises admin, owner,
member, and guest paths against the real DB-backed handler fixture and
asserts the field stripping + `can_manage` contract.

Refs: MUL-2413
Co-authored-by: multica-agent <github@multica.ai>

* fix(github): redact installation_id from realtime broadcasts (MUL-2413)

GET /github/installations strips the numeric installation_id for non-admin
members, but the github_installation:created / uninstall / suspend WS
events were still publishing it, so the same handle was reachable from
any workspace client subscribed to the workspace scope. Broadcast both
payload variants without it — the frontend uses these events only to
invalidate the installations query, so admins re-query the list endpoint
to recover the management handle.

Also adds a router-level test that mounts the production middleware split
(member-visible list vs. owner/admin connect+delete) so a future routing
change can't silently widen the write surface.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 04:17:45 +02:00
Naiyuan Qing
5b8303b83c fix(editor): fill modal viewport in attachment preview (MUL-2431) (#2891)
In the attachment preview modal, image and video previews used
`max-h-full max-w-full`, which let small assets render at their
natural size and leave the modal mostly empty. Switch to
`h-full w-full` so the preview always occupies the modal viewport,
relying on `object-contain` to preserve aspect ratio without
upscaling beyond the intrinsic bounds.

Only touches `packages/views/editor/attachment-preview-modal.tsx`
for the image (line 355) and video (line 373) branches; pdf, audio,
markdown, html, and text branches keep their existing layout.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 09:16:08 +08:00
Naiyuan Qing
071ffca034 fix(editor): exit list when Enter pressed on empty top-level item (MUL-2430) (#2861)
Tiptap's stock ListItem keymap binds Enter only to splitListItem. When the
cursor sits in an empty top-level list item, splitListItem returns false
(without dispatching) with a code comment saying "let next command handle
lifting" — but no next command is chained. Enter then falls through to
ProseMirror's baseKeymap which inserts another empty paragraph inside the
list item, trapping the user.

Replace StarterKit's ListItem with PatchedListItem whose Enter binding
chains splitListItem → liftListItem via commands.first. The lift fallback
only runs when splitListItem returns false (top-level empty case),
restoring the standard "double-Enter exits the list" behaviour seen in
every other rich-text editor. Non-empty and nested-empty items are
unaffected because splitListItem already handles them correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 08:20:50 +08:00
Jiayuan Zhang
2ad1cd8ff8 feat(profile): user profile description injected into agent brief (MUL-2406)
## Summary

Adds per-user `profile_description` so coding agents have cheap, durable context about who is asking. v1 per the brief Xeon locked in on [MUL-2406](mention://issue/63a7247c-4f6a-42cf-90d1-7c746e77158a):

- **DB** — `user.profile_description TEXT NOT NULL DEFAULT ''` (migration 096). 2000-rune cap enforced server-side. No nullable / privacy state to manage.
- **API** — `PATCH /api/me` accepts the field; `UserResponse` always emits it. Client wraps `updateMe` in a lenient `UserSchema` + `EMPTY_USER` fallback per CLAUDE.md API Response Compatibility.
- **UI** — Settings → Account gains an "About you" textarea with live `n/2000` counter, `maxLength` guard, and a localized too-long error (EN + zh-Hans).
- **CLI** — `multica user profile get` / `multica user profile update` with `--description / --description-stdin / --description-file / --clear`, mirroring the existing `issue comment add` input-mode menu.
- **Daemon injection** — claim handler resolves the runtime owner and stamps `requesting_user_name` + `requesting_user_profile_description` on the task. `buildMetaSkillContent` emits `## Requesting User` between `## Agent Identity` and `## Available Commands`, blockquoted and framed as background context. The block is omitted entirely when the description is empty (no token cost when unused).

Brief is written **once per task** via `CLAUDE.md` / `AGENTS.md`, not the per-turn prompt — same path the agent already reads for identity, so no extra per-turn cost.

## Test plan

- [x] `go build ./...`, `go vet ./...`, `go test ./internal/cli/ ./internal/daemon/ ./internal/daemon/execenv/ ./cmd/multica/`
- [x] New brief tests: `TestBuildMetaSkillContentEmitsRequestingUser`, `TestBuildMetaSkillContentOmitsRequestingUserWhenEmpty`
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm test` (74 files, 644 tests pass)
- [ ] Handler DB tests (`TestUpdateMe*`) require a migrated test DB — not runnable in this sandbox
- [ ] Manual: open Settings → Account, set a description, confirm the next daemon-run agent's `CLAUDE.md` shows `## Requesting User`
2026-05-19 19:51:28 +02:00
Jiayuan Zhang
34988216ed feat(issues): show project segment in issue breadcrumb (MUL-2422)
* feat(issues): show project segment in issue breadcrumb (MUL-2422)

Render the issue's project (when present) between the workspace and any
parent-issue segment. Segment reflects the issue's own `project_id` so
the same URL produces the same breadcrumb from every entry point.

Failed/missing project queries fall back to an "Unknown project"
placeholder; loading shows a skeleton to avoid layout shift.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): cap project breadcrumb width to preserve title precedence

Constrain Project crumb to max-w-72 (matching ProjectChip) and add
min-w-0 to the title span so the flex compression order matches RFC
§5/§9: Project/Parent shrink before the current Issue title.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:46:20 +02:00
Jiayuan Zhang
80cc7b23f8 refactor(runtimes): declutter the runtimes page (MUL-2407) (#2833)
* refactor(runtimes): declutter the runtimes page (MUL-2407)

Cuts visual noise on the Runtimes detail view without removing real
information:

- MachineDetail: drop the 4-card metric grid (RUNTIMES / HEALTH /
  WORKLOAD / CLI) and replace it with a single inline meta strip. The
  cards repeated what the title chip and runtime rows already show.
- PageHeaderBar: remove the inline tagline + "Learn more" link. The
  header is now icon + title + count + connect button.
- VisibilityBadge: only render the Public chip. Private is the default,
  so a row of `🔒 Private` badges was pure noise.
- CliCell: drop the per-row "Desktop" managed badge — the same string on
  every desktop row carried near-zero information.
- MachineSidebar row: hide the truncated daemon-id subtitle. The id is
  still available on hover via `title` and remains visible in the
  detail header.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): address review feedback on inline meta and hover title

- Inline meta now reads "6 runtimes · 5 online" instead of "6 6 online"
  by using runtime_count for the total label.
- Sidebar machine title hover now shows full daemon id (with subtitle
  fallback) so the daemon id is recoverable after the sub-row was hidden.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:15:46 +02:00
Jiayuan Zhang
044f7f0cc6 feat(editor): bump HTML iframe preview default height to 480px (MUL-2419) (#2842)
320px was too cramped for typical rendered HTML (charts, dashboards,
formatted documents). Matches the existing HTML attachment preview
height for visual consistency across both iframe surfaces.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:15:03 +02:00
Jiayuan Zhang
591e47842d refactor(onboarding): remove starter-content kit; unify install-runtime issue across mark-onboarded paths (MUL-2438) (#2884)
* refactor(onboarding): remove starter-content kit, unify install-runtime issue across mark-onboarded paths (MUL-2438)

Drops the post-onboarding ImportStarterContent / DismissStarterContent
flow (handler + routes + StarterContentPrompt + templates + locale
strings + analytics event). The bug — web onboarding seeding 6+ starter
issues without a runtime — only existed through that path; with it gone
the source disappears.

The "install a runtime" issue from BootstrapOnboardingNoRuntime is now
the canonical no-runtime onboarding seed. The title/description and a
LockAndFindActiveDuplicate-deduped seeder move to
handler/no_runtime_issue.go, and CompleteOnboarding / CreateWorkspace /
AcceptInvitation seed it whenever the workspace has no runtime yet, so
every mark-onboarded entry point lands the user on a concrete next
step.

starter_content_state column is kept and continues to be claimed as
'imported' in all five entry points so older desktop builds (which
still render the legacy dialog on NULL) don't surface it to accounts
created after this change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): backfill starter_content_state for in-window NULL users (MUL-2438)

054 only covered pre-feature users. Anyone onboarded between then and the
starter-content kit removal could still sit at NULL, and old desktop
clients gate the legacy StarterContentPrompt on `starter_content_state
IS NULL`. The import/dismiss routes are gone, so leaving these rows NULL
would surface a dialog whose buttons 404. Mark them 'imported' to match
the new helper's claim semantics.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 18:37:48 +02:00
Jiayuan Zhang
cd37b4e3d6 feat(settings): consolidate GitHub options under a dedicated Settings tab (MUL-2414) 2026-05-19 17:23:30 +02:00
Naiyuan Qing
06cb4674e0 Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-19 18:12:42 +08:00
Naiyuan Qing
f92deaf939 feat(desktop): foreground new tab for explicit Open-in-new-tab CTAs (MUL-2434) (#2869)
Add optional `opts.activate` to NavigationAdapter.openInNewTab. Default
stays `false` so cmd/ctrl+click on links/mentions keeps browser-style
background semantics. The two explicit toolbar entry points
(attachment-preview-modal, html-attachment-preview) opt in with
`{ activate: true }` so the new tab gains focus after the modal closes.

Both desktop providers (root + per-tab) now use the tab id returned by
`store.openTab` to call `setActiveTab` only when `activate` is true.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 18:11:37 +08:00
Bohan Jiang
f120e0ef43 refactor(cli): tidy workspace subtree (MUL-2386) (#2866)
- Drop `workspace current`; `workspace get` (no args) already prints the
  current default workspace, so the two were doing the same thing.
- Rename `workspace members` to `workspace member list` to free up the
  `member` namespace for future `add` / `remove` subcommands and align
  with the rest of the CLI's `<resource> <verb>` shape.
- Add `--full-id` to `workspace list`, matching `project list`,
  `autopilot list`, and friends.

Docs and the daemon prompt are updated to match.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:54:21 +08:00
Multica Eve
240792d5e0 docs: add 2026-05-19 changelog entry (#2863)
* docs: add 2026-05-19 changelog entry

Co-authored-by: multica-agent <github@multica.ai>

* docs: refine 2026-05-19 changelog copy

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:45:04 +08:00
Joey Frasier (Boothe)
76cd8275ff fix(openclaw): parse whole buffer instead of line-by-line scanner (MUL-1908) (#2292)
* fix(openclaw): parse whole buffer instead of line-by-line scanner

Follow-up to c87d7676 (WOR-10). The stdout/stderr swap fixed the dominant
case but `processOutput` still scanned line-by-line and only attempted a
whole-buffer parse from a fragile fallback path. Pretty-printed JSON
(openclaw 2026.5.x emits the result blob indented across many lines) made
every individual line unparseable on its own — `{`, `  "payloads": [`,
`    {`, etc. — so the success path hinged entirely on the fallback
joining `rawLines` and re-trying.

Under load (daemon restarts racing the close-on-cancel goroutine, partial
chunked reads when stdout closes mid-flight) the line scanner could see
truncated input that never reassembled into valid JSON, surfacing
"openclaw returned no parseable output" against runs where the agent had
in fact completed the work and posted comments. Roughly 30–40% of recent
runs in v0.2.27 logs hit this path; multica still wrote a `task_failed`
inbox row for each one even though the underlying issue had moved to
`in_review` or `done`.

The fix:

- processOutput now reads the full stdout buffer with `io.ReadAll` first.
- A new `parseWholeBufferOpenclawResult` helper attempts a single
  `json.Unmarshal` against the entire buffer (after trimming, and after
  optionally stripping leading non-JSON log lines). When it matches, we
  build the result and return — the line scanner never runs.
- If the whole-buffer parse fails, we fall through to the existing NDJSON
  line-by-line scanner. This preserves streaming-event support (kept for
  forward compatibility and other backends) without leaving openclaw's
  dominant pretty-printed shape at the mercy of timing.
- The failure path now emits a `(got N bytes; preview: ...)` suffix on
  the canonical "no parseable output" error so future debugging isn't
  blind. The exact canonical phrase is preserved for empty buffers so
  existing dashboards / log-grep tooling keep matching.

Tests:

- TestOpenclawProcessOutputWholeBufferPrettyJSON: feeds a hand-crafted
  multi-line indented blob (multiple payloads, nested agentMeta, usage
  map) and asserts every field round-trips through the whole-buffer fast
  path.
- TestOpenclawProcessOutputDeeplyIndentedFixture: re-runs the recorded
  openclaw 2026.5.5 stdout fixture (1070 lines) directly through
  parseWholeBufferOpenclawResult, asserting the bug-shape parses cleanly
  on the first attempt without falling through to NDJSON scanning.
- TestOpenclawProcessOutputEmptyBufferErrorIncludesByteCount: tightens
  the empty-buffer failure path, asserts the canonical phrase survives so
  observability tooling keeps working.

All existing tests in the openclaw + buildOpenclawArgs suites stay green
(streaming NDJSON event tests, lifecycle tests, structured-error tests,
usage-field-variant tests). The two pre-existing flaky timeout-tight
codex tests (TestCodexExecuteSemanticInactivityAllowsContinuous*) fail on
both this branch and on c87d7676 baseline; they are unrelated and out of
scope here.

Co-authored-by: multica-agent <github@multica.ai>

* fix(openclaw): drop dead preview branch, document streaming regression

Rebase + review-fix follow-up on top of f27df2d9b.

processOutput's preview branch was unreachable: openclawNoParseableOutputError
was only called from the `!gotEvents && trimmed == ""` path, which by
construction means the entire scanned buffer collapsed to whitespace, so the
`(got N bytes; preview: ...)` formatter could never fire on a non-empty buffer.
Replace the helper with a single canonical-string constant (callsite is now
inline) and update the test name to match what it actually asserts (the
canonical empty-buffer error string is preserved for external log-grep /
dashboard consumers).

Also document on processOutput that the line-scanner path is no longer
truly streaming after the io.ReadAll switch: events accumulate until
stdout closes. OpenClaw 2026.5.x does not emit streaming events so this
regression is invisible today, but flag it for the next backend that
might.

Misc: switch the scanner's input source from
`strings.NewReader(string(buf))` to `bytes.NewReader(buf)` to drop one
unnecessary byte/string round-trip.

MUL-1908

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J (Multica agent) <j@multica.local>
2026-05-19 17:42:41 +08:00
Naiyuan Qing
08a8f386d7 Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-19 17:34:36 +08:00
Naiyuan Qing
dbb8c0ade0 refactor(mobile): 5-tier surface elevation scale — fixes comment-bubble nested contrast + inline-code link confusion
Two related fixes that share root cause: shadcn's neutral palette
collapses `secondary` / `muted` / `accent` to the SAME L 96.1% value
intentionally — it's a single tonal slot whose semantic name varies by
use case, not three different colors. Stacking a bg-muted child on a
bg-secondary parent (which is what we were doing for code/table headers
inside the comment bubble) made the inner element visually disappear.

Introduce a proper 5-tier elevation scale calibrated to Refactoring UI
and Material 3 guidance:

  L 100   page bg / card / popover            (page floor)
  L 98    surface-1   NEW                     (subtle elevated — comment
                                               bubbles, iOS settings-cell
                                               feel: visible boundary
                                               via radius + border, fill
                                               is almost-page)
  L 96.1  secondary / muted / accent          (shadcn default, untouched —
                                               button hover, chips, skeleton)
  L 90    surface-2   NEW                     (nested inside surface-1 —
                                               table headers + code blocks
                                               inside comment bubbles, 8% L
                                               step over surface-1)
  L 84    border      (was 89.8% → 84%)       (visible across every tier,
                                               6-16% darker than adjacent
                                               surface, within Refactoring
                                               UI's 5-10% guideline)

Dark mirror flips the lightness direction (higher elevation = lighter):
page 3.9 → surface-1 8 → secondary 14.9 → surface-2 19 → border 25.

Applied across three files:

  - global.css + tailwind.config.js + lib/theme.ts mirror the new tokens
    (CSS variables, Tailwind class map, TypeScript export — they must
    stay in sync per CLAUDE.md §5).

  - components/issue/comment-card.tsx switches the bubble bg from
    `bg-secondary` (too prominent, same color as inner muted elements)
    to `bg-surface-1` (subtle, 8% lighter than inner surface-2).

  - lib/markdown/markdown-style.ts:
      - table.headerBackgroundColor + codeBlock.backgroundColor:
        `t.muted` → `t.surface2`, so they're framed against the bubble.
      - inline `code:`: REVERT 2026-05-19's `color: t.brand` workaround
        for upstream enriched-markdown #255. The brand-tint avoided the
        chip's top-heavy padding artifact but broke Refactoring UI's #1
        rule (color carries semantic meaning — brand IS the link color,
        users reported tapping inline code thinking it was a link).
        Re-enable bg-chip + foreground text, matching GitHub mobile /
        Slack / Notion / Apple Notes. The padding artifact is the lesser
        evil; in surface-2 (L 90%) on surface-1 (L 98%) the chip is
        subtle enough that the few pixels of asymmetry are unobtrusive.

The shadcn `secondary` / `muted` / `accent` tokens stay at L 96.1%
unchanged — other call sites (button hover, skeleton, avatar fallback,
chips) all work fine on their own and were never the problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:41 +08:00
Naiyuan Qing
27c171f30c feat(mobile): attribute row — project picker wired + all pickers go pageSheet
Issue-detail AttributeRow chip row (status / priority / assignee / label /
project / due-date) had three nagging gaps. Fix them together so the
whole row behaves consistently.

  - ProjectPickerSheet was never wired: the file existed (155 lines, ready
    to use) but the chip was read-only with a stale `// picker deferred
    until web ships one` comment. Web has had a project picker forever.
    Add the projectOpen state, an `onProject` handler that calls
    `useUpdateIssue.mutate({ project_id })`, a placeholder dimmed chip
    when no project is set, and mount the sheet. Mobile users can now
    change an issue's project.

  - PRIORITY_LABEL was duplicated in two places — re-declared inside
    priority-picker-sheet.tsx (full form `none: "No priority"`) and as a
    near-identical chip placeholder in attribute-row.tsx (short form
    `none: "Priority"`). Both now import from the single source in
    `lib/issue-status.ts`; attribute-row keeps a 1-key override
    (`PRIORITY_CHIP_LABEL = { ...PRIORITY_FULL_LABEL, none: "Priority" }`)
    so the chip placeholder still reads as a placeholder, not as an
    assigned value.

  - Sheet container split was inconsistent: assignee / label / project
    pickers used SheetShell pageSheet (slide-up from bottom), while
    status / priority / due-date used a centered transparent Modal card
    (different gesture, different position). For a chip row where users
    tap several pickers in succession, the inconsistency broke iOS
    muscle memory. Status / priority / due-date all switch to pageSheet
    so the whole row reads as "tap chip → slide-up sheet" uniformly.
    Linear iOS / Things 3 / Apple Reminders use this pattern even for
    short fixed lists.

CLAUDE.md Lesson #6 modal container table grew a "picker-row consistency
wins over per-container optimisation" carve-out so future row-of-pickers
work follows the same rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:15 +08:00
Naiyuan Qing
b39e3a90c0 fix(mobile): activity ↔ web parity — start_date / squad_leader / wording
Five small fixes that close the remaining gaps between mobile's activity
rendering and the web equivalent in packages/views/issues/components/
issue-detail.tsx. All logic-layer; no component or container changes.

  - timeline-coalesce.ts: add NEVER_COALESCE_ACTIONS = {squad_leader_
    evaluated}. Without it, two consecutive squad-leader evaluations from
    the same actor within 2 min merged into one row, dropping the second's
    `outcome` + `reason` audit fields. Web does this since the rule was
    added; mobile was missing it.

  - format-activity.ts: add cases for `start_date_changed` (set / remove
    branches) and `squad_leader_evaluated` (outcome × reason 4 branches).
    Before, both fell through to the default that returns the raw enum
    name — users saw literal `start_date_changed` / `squad_leader_
    evaluated` strings in the timeline.

  - format-activity.ts: tighten assignee wording from "assigned NAME" to
    "assigned to NAME" — matches web's en/issues.json copy.

  - activity-row.tsx: `LeadIcon` now reuses CalendarGlyph for
    `start_date_changed` (same affordance as `due_date_changed`).

  - components/inbox/detail-label.tsx: TYPE_LABEL Record was missing
    `start_date_changed` — fixes a pre-existing TS error.

  - data/schemas.ts: EMPTY_ISSUE_FALLBACK was missing `start_date: null`
    — fixes the other pre-existing TS error. Both gaps had the same root
    cause (backend added the field, mobile didn't follow).

Typecheck is now clean — no pre-existing errors remaining.

Copy strings mirror packages/views/locales/en/issues.json verbatim
(activity.start_date_set / squad_leader_action / etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:31:57 +08:00
Bohan Jiang
54368fd826 feat(projects): scheduled-only Gantt data source + WS reactivity (MUL-1881) (#2856)
* feat(projects): scheduled-only Gantt data source + WS reactivity (MUL-1881)

Project Gantt now fetches its own scheduled-only data instead of riding the
Board/List pagination cache. The Unscheduled drawer and pagination warning
banner are gone, and any WS-driven issue change (create / update / delete)
invalidates the new cache so the timeline stays live.

- Backend: `GET /api/issues?scheduled=true` adds an
  `(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)` predicate on both
  ListIssues and CountIssues. New SQL filter is plumbed through sqlc + handler.
- Frontend: new `projectGanttIssuesOptions(wsId, projectId)` issues a single
  fetch and lives under its own cache key. WS handlers and mutations
  invalidate the prefix on create/update/delete so the bar reacts to
  start_date / due_date changes from other tabs and from this tab without
  waiting on the WS round-trip.
- GanttView: drops the Unscheduled section, the pagination warning banner,
  and the load-all button; renders only scheduled rows.
- Removes now-dead `useLoadAllRemaining`, `myIssueListPaginationOptions`,
  `summarizeIssueListPagination`, and the gantt locale strings that
  supported the old plumbing.

Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): page through Gantt fetch and isolate per-view data sources

- Walk paginated `scheduled=true` issues until total is reached so projects
  with more than 500 scheduled bars no longer silently truncate.
- Gantt mode disables the bucketed Board/List query and reads its own
  scheduled cache for the project empty-state check, so the page never
  short-circuits Gantt with a Board-derived "no issues" CTA.
- `onIssueLabelsChanged` patches matching rows in the Project Gantt cache
  in-place, keeping label filters consistent after attach/detach from
  other tabs or agents.

MUL-1881

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:04:16 +08:00
Naiyuan Qing
d46e90ee0a refactor(editor): keep <Attachment> image rendering as a pure port of the original ImageView (#2857)
Earlier the unification commit dragged in a Tailwind override stack
(ring, rounded-md, transition-shadow, bg-background/95, button hover
classes) "to make standalone surfaces work without .rich-text-editor
scope". Because the legacy CSS rules were not removed, both layers
applied in the editor, producing a visible double-stroke selection
ring and a light-theme hover on top of the dark-glass toolbar.

This commit reverts the styling churn:

- ImageAttachmentView now emits the same span-only DOM as the original
  ReadonlyImage: <span.image-node> > <span.image-figure> > <img.image-content>
  + <span.image-toolbar> with naked <button> children. No Tailwind tax.
- The `.image-*` rules in content-editor.css are de-scoped from
  `.rich-text-editor` so the single set of styles also drives chat /
  AttachmentList renders. Editor-only behavior (640px cap, NodeView
  centering) stays under the `.rich-text-editor` scope.
- A `data-clickable` attribute carries the "this image is clickable
  to preview" hint that the readonly cursor rule used to key off the
  `.rich-text-editor.readonly` scope.
- ImageView NodeViewWrapper no longer adds its own `image-node` class
  because `<Attachment>` already emits one; the duplicate was harmless
  but redundant.

Visual: editor + readonly comments render identical to before. Chat /
AttachmentList previously rendered a gray file card for images (the
P0 fix in the parent commit) and now match the editor visual without
the heavy-handed Tailwind detour.

Tests: 98 attachment-related tests pass; full `pnpm typecheck` + `pnpm
test` (652 tests) green.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:58:45 +08:00
Naiyuan Qing
6901325761 fix(desktop): open HTML preview in background tab and close modal (MUL-2418) (#2854)
Two independent root causes made "Open in new tab" on a desktop
attachment-preview modal feel like "the popup is still there and the
current tab got replaced":

1. `AttachmentPreviewModal.handleOpenInNewTab` never called `onClose()`,
   so the modal stayed mounted over the new tab.
2. Both `DesktopNavigationProvider.openInNewTab` and
   `TabNavigationProvider.openInNewTab` called
   `store.setActiveTab(tabId)` after `store.openTab(...)`, which stole
   focus to the new tab — violating the type contract
   ("Desktop only: open a path in a new background tab") and matching
   neither Chrome's cmd+click default nor the user's expectation.

Fixes:
- Modal: always call `onClose()` after dispatching the navigation
  (desktop adapter path and web `window.open` fallback path).
- Desktop navigation: drop the post-`openTab` `setActiveTab` call in both
  providers. `openTab` already preserves `activeTabId` for new paths and
  switches to the existing tab when the path is already open, which is
  exactly the background-tab semantics the type contract advertises.

Tests:
- `attachment-preview-modal.test.tsx`: assert `onClose` is invoked on
  both the desktop and web fallback branches.
- `pageview-tracker.test.tsx`: rename the "openInNewTab / addTab" case
  so the comment no longer claims `openInNewTab` activates the new tab.
- New `apps/desktop/.../platform/navigation.test.tsx`: assert that
  `openInNewTab` on both providers calls `openTab` and never
  `setActiveTab` for same-workspace paths, and routes cross-workspace
  paths through `switchWorkspace`.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:45:18 +08:00
Naiyuan Qing
c49c78b780 fix(editor): make in-iframe #fragment links scroll in HTML attachment preview (MUL-2417) (#2855)
HTML attachment previews mount the document inside a sandboxed
`<iframe srcdoc>` deliberately WITHOUT `allow-same-origin` — uploads are
untrusted user content. Chromium treats fragment-link clicks inside such an
opaque-origin srcdoc iframe as cross-origin frame navigation and silently
rejects them, so clicking a TOC entry never scrolls.

Append a tiny shim script to the srcdoc that intercepts `<a href="#...">`
clicks inside the iframe and calls `scrollIntoView` directly. The shim runs
in the iframe's own opaque origin under `allow-scripts` — no new
capabilities, no sandbox token changes; it cannot reach parent / cookies /
localStorage.

All three HTML attachment surfaces share the same helper:
  - inline 480px card  (html-attachment-preview.tsx)
  - full-screen modal  (attachment-preview-modal.tsx)
  - full-page route    (attachment-preview-page.tsx)

References: whatwg/html#3537, crbug 40191760.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:44:55 +08:00
Bohan Jiang
fd6ccbe371 feat(desktop): forward renderer console + crash events to main stderr in dev (#2853)
When the renderer crashes hard enough to leave a white window (React
boundary unrecoverable, syntax error during initial mount, preload
script throw), DevTools can't be opened and the only signal in the
`make dev` terminal is the daemon-manager 5s polling complaint
("Render frame was disposed before WebFrameMain could be accessed").
That's a downstream symptom — the actual JS error is unreachable, so
the user has no path to diagnose without restarting the renderer
(which loses the failure mode entirely).

Add four webContents listeners on the main BrowserWindow, gated by
`is.dev` so packaged builds keep their stderr clean:

- `console-message`: forwards every renderer `console.*` to main's
  stderr with file:line. React error boundaries, `window.onerror`, and
  unhandled-rejection handlers all surface here.
- `render-process-gone`: serialises the GoneDetails (`crashed` / `oom`
  / `killed` / `launch-failed`) so the user sees *why* the renderer
  died, not just that it did.
- `did-fail-load`: catches loadURL/loadFile failures. Skip
  `errorCode === -3 (ABORTED)` because that's the normal HMR-induced
  navigation abort.
- `preload-error`: the one error class DevTools can never show, because
  preload runs before the window owns a console. Without this listener
  preload throws are invisible.

All output is prefixed with `[renderer <tag>]` so it's easy to grep
distinct from main's own logs.

No behavioural change in production: the entire block is inside an
`is.dev` guard. Packaged builds keep their existing stderr.
2026-05-19 16:42:12 +08:00
Naiyuan Qing
39f43a9a98 refactor(editor): unify attachment rendering into a single <Attachment> component (#2850)
Collapse the five separate attachment render paths (file-card NodeView,
image NodeView, readonly markdown img/fileCard renderers, AttachmentList
standalone fallback, and the parallel packages/ui/markdown renderer) into
one <Attachment attachment={a} /> dispatcher.

Fixes a P0 visual regression: a PNG attached to a message but not inlined
in the markdown body used to render as a gray "file card" because
getPreviewKind() lacked an "image" branch and image rendering bypassed
the dispatcher entirely. Now every surface routes through <Attachment>,
so the same PNG renders as a real <img> with hover toolbar and
preview-modal everywhere.

Key changes:
- PreviewKind gains "image"; getPreviewKind() detects image/* + common
  extensions before the html/text branches (so svg stays image, not text).
- AttachmentPreviewModal gains case "image" (replaces the standalone
  ImageLightbox, which is deleted).
- New packages/views/editor/attachment.tsx owns all kind-aware routing
  (image | html | file) and dispatches preview modal + download via the
  existing useAttachmentPreview / useDownloadAttachment hooks. Subsumes
  the deleted AttachmentBlock.
- AttachmentInput.url accepts a forceKind hint so callers that *know*
  the structural kind (markdown ![](url), Tiptap image node) skip the
  filename-based autodetect — fixes a regression where empty or
  descriptive alt text would route an image to the file-card chrome.
- Tiptap NodeViews (file-card.tsx, image-view.tsx) shrink to thin
  wrappers that forward editor hints (selected, deleteNode, uploading)
  to <Attachment>.
- ReadonlyContent and AttachmentList each mount their own
  AttachmentDownloadProvider so url → record resolution works outside
  ContentEditor's provider.
- packages/ui/markdown gains optional renderImage / renderFileCard slot
  props; packages/views/common/markdown.tsx injects <Attachment> into
  those slots and threads message attachments through to chat /
  skill-file viewers.
- chat-message-list passes message.attachments to every <Markdown> call
  site and renders a standalone AttachmentList under each bubble for
  attachments not referenced in the body.

Tests: attachment.test.tsx covers 9 scenarios (record image / pdf / html;
url-only image with resolver hit and miss; uploading state; editable
delete; forceKind regression). attachment-preview-modal.test.tsx gains
image-dispatch cases. 652/652 unit tests pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:19 +08:00
Kagura
59617f376e feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var (MUL-2371) (#2713)
* feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var

Add AUTH_TOKEN_TTL environment variable (in seconds) to override the
hardcoded 30-day auth token lifetime. Self-hosted deployments on trusted
networks can set a longer value to avoid frequent magic-link
re-authentication.

The value is read once at startup and cached. Invalid or missing values
fall back to the 30-day default with a warning log.

Closes #2685

* refactor(auth): extract parseAuthTokenTTL for testability

Address review feedback: extract pure parse function from sync.Once
wrapper so the parsing logic can be unit-tested independently.
Add TestParseAuthTokenTTL with table-driven cases.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* refactor(auth): accept Go duration strings + hoist shared TTL in SetAuthCookies

Address nice-to-have review feedback from Bohan-J:
- parseAuthTokenTTL now tries time.ParseDuration first (e.g. '8760h'),
  falling back to ParseInt for integer seconds
- Warn on unreasonable values (>10 years) but still accept them
- Hoist AuthTokenTTL() and time.Now() in SetAuthCookies so both
  cookies share the exact same expiry
- Add security trade-off note in .env.example
- Add 5 new test cases for duration strings

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix: use AuthTokenTTL() in CloudFront middleware, guard ParseInt overflow

Address review feedback from Bohan-J (round 2):

1. CloudFront refresh middleware (cloudfront.go:21) was hardcoding
   30*24*time.Hour instead of using auth.AuthTokenTTL(). Now calls
   AuthTokenTTL() so the middleware respects AUTH_TOKEN_TTL env var.

2. parseAuthTokenTTL integer-seconds branch: very large values like
   9999999999 would silently overflow int64 when multiplied by
   time.Second. Added overflow guard comparing against
   math.MaxInt64/int64(time.Second) before the multiplication.

3. Updated AuthTokenTTL() doc comment to reflect that it accepts
   Go duration strings or integer seconds (not just seconds).

4. Added middleware test (cloudfront_test.go) verifying short
   AUTH_TOKEN_TTL produces short cookie expiry, not 30-day hardcode.
   Also covers nil signer and existing-cookie-skip cases.

5. Added integer overflow test case to cookie_test.go.

* style: run gofmt on cookie.go and cookie_test.go

---------

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-05-19 16:22:07 +08:00
Bohan Jiang
9a577f3e11 fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir (MUL-2416) (#2849)
* fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir

OpenCode resolves its project discovery root from `--dir` and `PWD`
before falling back to `process.cwd()`. The daemon set `cmd.Dir =
workDir` but never overrode the inherited `PWD`, so OpenCode walked
from the daemon's shell directory and silently bypassed the per-task
workdir — agents lost visibility into `.opencode/skills/` and
`AGENTS.md`, falling back to whatever global skills the host had
installed (MUL-2416).

- Pass `opencode run --dir <workDir>` and override `PWD=<workDir>` in
  the child env so AGENTS.md walk-up + `.opencode/skills` project
  config scan both anchor on the task workdir.
- Block `--dir` from custom args so user overrides cannot re-introduce
  the regression.
- Plumb skill `description` from DB through service / daemon /
  execenv. `writeSkillFiles` synthesizes a YAML frontmatter block
  (`name`, optional `description`) when the stored content lacks one,
  since runtimes like OpenCode silently drop SKILL.md files without a
  parseable `name`. Existing frontmatter is preserved unchanged so
  upstream-imported skills (GitHub / ClawHub / Skills.sh) keep their
  hand-shaped metadata.

Tests:
- New fake-CLI test confirms argv carries `--dir <workDir>` and the
  child sees `PWD=<workDir>`.
- New test confirms a user-supplied `--dir` in custom_args is dropped.
- New execenv tests cover synthesized frontmatter and preservation of
  pre-existing frontmatter.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): inject SKILL.md `name` when upstream frontmatter omits it

Skills imported with frontmatter that sets `description` but leaves `name`
implicit (relying on the directory slug, as common in GitHub/Skills.sh
imports) still hit OpenCode's "no parseable name → drop" path because the
DB Name fallback never made it into the SKILL.md body. ensureSkillFrontmatter
now scans the existing block and, when name is missing or empty, prepends
`name: <slug>` while preserving description, body, and any runtime-specific
keys verbatim.

Also tighten yamlEscapeInline to always double-quote so descriptions that
look like YAML keywords (`null`, `true`, `[foo]`, `{x: y}`, `2024-01-01`)
parse as strings rather than getting reinterpreted and rejected.

Adds regression test for the nameless-frontmatter case and updates the
existing OpenCode skill test for the always-quoted description format.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:21:02 +08:00
Naiyuan Qing
e9b2e98d15 fix(mobile): PulseDot uses brand colour, not success — running ≠ completed
The agent "is working" pulse dot (shown both in the issue Stack header
ambient badge and in the in-card AgentActivityRow "Working" row) was
backgroundColor #22c55e — that's the success/completed token. Reading
green here meant "task complete", which is the opposite of what the
animation represents.

Switch to THEME[scheme].brand (hsl(225 71% 58%)), matching:
  - mobile RunRow status text: STATUS_CLASS.running = "text-brand"
  - web agent-live-card.tsx:327: <Loader2 text-info animate-spin />
  - Apple HIG / shadcn semantic colour convention:
      green = success, blue/brand = in-progress, red = destructive

One-line fix in pulse-dot.tsx; both call sites (AgentHeaderBadge top-right,
AgentActivityRow under the title) flip from green to brand blue
together. Docstring updated to spell out the rule for future readers:
DO NOT use success here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:41 +08:00
Naiyuan Qing
9aaff146b5 refactor(mobile): close button uses <IconButton variant=secondary>
Both the SheetShell (pageSheet header) and the standalone ModalCloseButton
(modal Stack header) were drawing the circular grey close ✕ by hand:
<Pressable> + <View bg-secondary> + <Ionicons color="#3f3f46">. Two
problems with that pattern:

1. The #3f3f46 zinc-700 hex is invisible in dark mode — the icon and
   background both go dark, contrast collapses.
2. It bypasses RNR Button (which is exactly what an icon button is),
   re-implements active state, and lives outside the design system.

Swap both to <IconButton name="close" variant="secondary"
className="size-7 rounded-full"> — RNR Button under the hood, secondary
variant carries the bg-secondary token (so dark mode flips), icon color
comes from useTheme(). className locks the 28pt circular shape that
Linear iOS / Things 3 use for this slot (RNR's default size="icon" is a
40pt rounded-md square box, which is a different look).

One-line fix per file, no new primitive. Affects every pageSheet
close button (RunsSheet, picker sheets via sheet-shell) and every modal
close button (new-issue, search, new-comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:23 +08:00
Naiyuan Qing
95979515b2 feat(mobile): issue detail — comment-as-modal route, hex/Pressable cleanup, API helpers
Comment composer redesign (user feedback: inline always-on was clunky,
keyboard avoidance bad, no room for @mention suggestion bar). The bottom
of issue/[id].tsx is now a single <Button>Comment</Button>; tap pushes
the new issue/[id]/new-comment modal — full screen for typing,
AutosizeTextArea + MentionSuggestionBar + toolbar. Reply path goes
through the same modal with parent / parentName route params, so
"Reply" on a comment long-press just pushes the modal in reply mode.

Comment-card long-press no longer competes with iOS native text
selection: wrapped <Markdown> in a View with userSelect:'none' so the
press only triggers the action sheet. Users can still copy the full
comment body via the existing "Copy text" entry.

issue/[id].tsx headerRight 3-dot menu switches from a hand-drawn
Pressable + Ionicons (hardcoded #0a84ff/#71717a) to <IconButton>. Same
hex cleanup applied to:
  - agent-activity-row.tsx (2× #a1a1aa → THEME.mutedForeground)
  - activity-row.tsx (MUTED constant deleted; SVG glyph takes stroke prop)
  - comment-card.tsx BRAND_RING/BRAND_WASH rgba constants gone — animated
    overlays now use NativeWind border-brand/50 + bg-brand/5 classes,
    opacity stays the only animated channel.

API layer: 5 issue GET methods migrated to fetchValidated (getIssue,
listTimeline, listAttachments, listActiveTasksForIssue, listTasksByIssue).
Write endpoints stay on raw this.fetch per the existing mobile convention
— migrating writes needs new zod schemas, defer to a follow-up PR.

comment-composer.tsx deleted: orphan after the modal swap. CommentActionSheet
is kept as-is — it has the quick-react emoji row (the only "add reaction"
entry for comments) and already follows the correct Lesson 6 short-action
card pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:09 +08:00
Bohan Jiang
7be3838ada feat(transcript): add sort direction toggle to agent transcript dialog (MUL-2368) (#2848)
Adds a header toggle that lets users flip the agent transcript between
chronological (oldest first, current behavior) and newest-first. The
preference is persisted via a small Zustand store. Default stays
chronological so existing readers see no behavior change.

Sort is a pure presentation concern — the underlying timeline (seq
numbers, filter keys, segment navigation) is untouched. Toggling resets
the scroll container to the top so the user lands on the newest end of
the chosen direction. Copy-all respects the displayed order so the
exported text matches what's on screen.

Scope is limited to the task transcript dialog per the MVP plan; the
issue execution log and agent activity tab are out of scope and may be
revisited once this interaction validates.

Closes GH #2736.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:03:31 +08:00
Bohan Jiang
98ef021d1d feat(projects): add Project Gantt view (MUL-1881) (#2843)
* feat(projects): add Project Gantt view (MUL-1881)

Adds Gantt as a third option in the Project page's view toggle (Board /
List / Gantt). Bars span start_date → due_date; issues with only one
date render as markers, issues with neither are collapsed into an
Unscheduled section. Toolbar exposes day/week/month zoom and a
show-completed toggle. The Gantt view shares the existing IssuesHeader
filters/sort.

Implementation is self-rendered SVG/HTML — no new dependencies. UTC
day-aligned date math keeps bars on the right columns regardless of
viewer timezone.

Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): scope Gantt to project surface + warn on hidden pages

- IssuesHeader / IssueDisplayControls now take `allowGantt` (default false);
  only Project Detail opts in. /issues, /my-issues and the actor panel no
  longer expose a Gantt option that silently fell through to List, and the
  toggle icon falls back to List when a stored `viewMode === "gantt"` lands
  on a surface that doesn't render it.
- Project Gantt now surfaces a banner with hidden-issue count plus a
  Load-all action that drains every remaining paginated page into the
  cache via the new `useLoadAllRemaining` helper. Pagination summary comes
  from `myIssueListPaginationOptions`, which shares the existing cache key
  with `myIssueListOptions` so totals stay in sync with Board/List.
- ScheduledRow normalizes a `start_date > due_date` anomaly to min/max and
  outlines the bar with a destructive ring + tooltip note, instead of
  silently dropping the row.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 15:55:57 +08:00
Jiayuan Zhang
6f21cb8f3e [codex] Simplify onboarding runtime bootstrap (#2836)
* feat(onboarding): simplify runtime bootstrap

* fix(onboarding): close private-helper reuse hole and guide-issue nav race

- server: when bootstrap looks for an existing Multica Helper, require
  Visibility="workspace" so a private helper owned by another member
  can't be auto-assigned to the onboarding issue (and trigger a task as
  that private agent), which would have bypassed canAccessPrivateAgent.
- web onboarding page: refreshMe() inside bootstrap flips hasOnboarded
  before onComplete fires, letting the guard's router.replace overtake
  onComplete's router.push to the new guide issue. Mark the page as
  "completing" right before navigating so the guard stays silent during
  the in-flight transition.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): escape daemon command literals to satisfy i18next/no-literal-string

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
2026-05-19 09:52:35 +02:00
Naiyuan Qing
fcd99c0084 docs(mobile): encode helper-layer conventions + swipe & Tier C lessons
CLAUDE.md grew with rules surfaced by the inbox PR + the earlier WS / API
helper work, so future agents can find the helpers instead of recreating
them.

New section "Data layer helpers" — three rails (logic mirrors web; use
existing components, don't invent primitives; use the wrapped request
layer) + helper-by-helper reference (fetchValidated, fetchValidatedWith,
xKeys factory shape, ws.on<E>() + WSEventPayloadMap, useWSSubscriptions,
synchronous-setQueryData-before-await ordering) + a 7-step checklist for
new features.

Realtime strategy extended with "Cross-cutting cache patches across
features" — the rule that issue:* → inbox-cache patches live in
inbox-ws-updaters.ts (owned by the feature being patched), not in issues'
own hook. Reconnect table updated to use inboxKeys.list(wsId).

Two new Lessons:
  - Lesson 7: destructive swipe is reveal-only, never auto-fire; haptic
    via useAnimatedReaction + runOnJS at the threshold. Encoded from the
    inbox PR's swipe UX fix.
  - Lesson 8: Tier C domain components (ActorAvatar, StatusIcon, etc.)
    upgrade opportunistically — don't silently rewrite when you're just
    rendering them in a new feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:08:21 +08:00
Naiyuan Qing
ace56f80c8 feat(mobile): inbox refactor — Mark all read, swipe UX, parity fixes
Swipe-to-archive no longer auto-fires on full drag (felt aggressive, no
peek, easy mistrigger on fast scroll). Now matches iOS Mail / Linear: drag
reveals the red Archive button + medium haptic at threshold, user taps to
commit. Auto-fire path removed; useAnimatedReaction + runOnJS bridges the
UI-thread shared value to Haptics.impactAsync.

Behavioral parity fixes the previous mobile inbox was missing vs web:
  - Mark all read action — endpoint POST /api/inbox/mark-all-read already
    existed server-side; mobile just never wired it. Added api.markAllInbox
    Read + useMarkAllInboxRead (optimistic flip read=true on non-archived)
    + ActionSheet menu entry as the first option.
  - issue:updated → patch inbox row's StatusIcon inline. Previously mobile
    ignored the event and showed stale status until the next inbox event
    refetched the list.
  - issue:deleted → strip orphaned inbox rows so tapping doesn't 404 on
    the issue detail page.
  - Both via a new mobile-owned inbox-ws-updaters.ts mirroring web's
    packages/core/inbox/ws-updaters.ts.

Internal cleanup:
  - inboxKeys factory in data/queries/inbox.ts ({all,list}, 3-segment
    shape matching web). 6 inline ["inbox", wsId] strings retired across
    queries / mutations / realtime / useCreateIssue inbox invalidate.
  - Synchronous setQueryData hack (workaround for iOS push transition
    snapshot capturing pre-flip state) moved from inbox.tsx caller into
    useMarkInboxRead.onMutate. Every caller benefits, none can forget it.

UX polish:
  - Loading state: 6 Skeleton rows (RNR, installed this PR) replacing
    centered ActivityIndicator.
  - Empty state: mail-open icon + helper text replacing bare "No inbox
    items." copy.
  - ItemSeparatorComponent ml-[60px] → ml-16 (token, aligns with avatar
    36 + px-4 + gap-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:08:07 +08:00
Bohan Jiang
d7e58760f3 fix(runtimes): exempt CLI command literals in Connect Remote dialog from i18n rule (#2841)
The two `<code>` blocks in the "having trouble?" disclosure of the
Connect Remote dialog render literal shell commands ("multica daemon
status" and "multica daemon logs -f"). The `i18next/no-literal-string`
rule (enforced as error across packages/views) flagged them, turning
@multica/views#lint red on main since the dialog landed.

These strings are inherently locale-agnostic — they are the actual
commands users type into a shell, identical in every language. Wrapping
them in t() would be wrong (translators would have no source-of-truth
about whether the binary name `multica` or the subcommand `daemon` could
be translated; the answer is "never").

Mark them as exempt with `eslint-disable-next-line i18next/no-literal-string`
+ a one-line comment explaining why. Mirrors how shell-command snippets
are treated elsewhere in the repo.

Verification:
- `pnpm --filter @multica/views lint` → 0 errors (was 2). 13 remaining
  warnings are pre-existing in other files and don't fail CI.
- Cascaded failures (@multica/views#typecheck, web/desktop builds) on CI
  were strictly downstream of the lint failure; they'll go green once
  lint passes.
2026-05-19 15:01:40 +08:00
Bohan Jiang
6e0f7b0f36 feat(settings): allow editing workspace issue prefix (MUL-2369) (#2809)
* feat(settings): allow editing workspace issue prefix (MUL-2369)

Workspace admins can now change the issue prefix from Settings → General.
The change is gated by a confirmation dialog that warns about external
references (PR titles, branch names, links) breaking, because issue
identifiers are rendered as `prefix-N` on the fly — changing the prefix
effectively renames every existing issue.

Refs https://github.com/multica-ai/multica/issues/2797

Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): invalidate issue cache when workspace prefix changes (MUL-2369)

Issue identifiers (`MUL-123`) are recomputed from `workspace.issue_prefix`
at read time, so cached issues kept showing the old `OLD-N` keys after a
prefix change. Without invalidation the confirm dialog's "all issues will
be renumbered" promise was broken until a hard refresh — and other tabs
receiving the `workspace:updated` WS event saw the same drift.

- WorkspaceTab: after a prefix-changing save, invalidate `issueKeys.all`
  in addition to the workspace list. Non-prefix saves stay cheap.
- Realtime: split `workspace:updated` out of the generic `workspace`
  refresh into a specific handler that compares cached vs incoming
  `issue_prefix` and invalidates issues only when it actually changed.
- Docs: align the "uppercase" language with the actual UI/backend rule
  (uppercase letters and digits, up to 10 chars).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:47:34 +08:00
Bohan Jiang
b5102eb3d2 feat(cli): add workspace switch + current commands (MUL-2386) (#2838)
`multica workspace switch <id|slug>` is the product-semantic entry point for
changing the default workspace on the current profile. It looks the target up
in the user's accessible workspace list (an access check by construction —
the server only returns workspaces the user is a member of), persists the
chosen UUID via the existing CLI config layer, and prints the resolved name.
`config set workspace_id` stays as the low-level escape hatch.

`multica workspace switch` resolves the workspace before saving, so an
unknown id or slug fails fast and leaves the previous default intact.

`multica workspace current` and a `*` marker in `multica workspace list`
expose which workspace commands without --workspace-id/MULTICA_WORKSPACE_ID
will target. `multica login` reuses the same marker when listing discovered
workspaces and points multi-workspace users at switch.

Docs gain a "Working with multiple workspaces" section spelling out the
resolution priority (--workspace-id flag > env > profile default) and
calling out config set workspace_id as low-level.

Addresses GitHub#2750.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:43:20 +08:00
Bohan Jiang
e19f7967b9 feat(prompt): thread-first comment reads for agent runs (MUL-2387) (#2816)
* feat(prompt): thread-first comment reads for agent runs (MUL-2387)

PR #2787 added --thread / --recent / --before / --before-id to the
ListComments API and CLI but kept the agent prompt steering at the
legacy "dump everything" recipe. On a long-running issue the flat dump
burns context on chatter unrelated to the trigger; agents acting on the
trigger want the trigger's thread first.

Prompt updates:

- Comment-triggered Workflow (runtime_config.go) now anchors step 2 on
  `multica issue comment list <issue-id> --thread <trigger-comment-id>
  --output json`. Fallback offers `--recent 20 --output json` with the
  stderr `Next thread cursor: --before <ts> --before-id <root-id>` line
  feeding the next-page cursor. `--since` is preserved and explicitly
  marked combinable with --thread / --recent.
- Per-turn buildCommentPrompt (prompt.go) carries the same thread-first
  guidance so a Codex-style runtime that re-reads the per-turn message
  every iteration gets the same steering, even if it ignores the
  injected runtime config.
- Assignment-triggered Workflow keeps the mandatory full-history rule
  (MUL-1124) but now also points at `--recent 20` as the long-issue
  alternative — this is the place that previously had no thread-aware
  guidance at all.
- Default fallback prompt (no trigger comment, no chat, no autopilot,
  no quick-create) gains the same --recent hint without --thread (no
  comment to anchor on).
- Available Commands core line surfaces the new flags so the discovery
  path matches the workflow guidance.

Default CLI/API semantics are unchanged: the unparameterized list still
returns the full chronological dump capped at 2000, --since still works
on its own, and the desktop UI is untouched.

Tests:

- prompt_test.go: TestBuildPromptCommentTriggerPromotesThreadReads pins
  --thread <triggerID>, --recent 20, the stderr cursor phrasing, and
  the absence of the legacy "returns all comments" prose.
- prompt_test.go: TestBuildPromptDefaultMentionsRecent guards the
  no-trigger fallback (mentions --recent, must NOT mention --thread).
- execenv_test.go: TestInjectRuntimeConfigCommentTriggerThreadFirstReads
  asserts the comment-triggered Workflow steers at --thread/--recent,
  the Available Commands line surfaces the new flags, and the legacy
  "read the conversation (returns all comments...)" string is gone.
- execenv_test.go: TestInjectRuntimeConfigAssignmentTriggerMentionsRecent
  keeps the mandatory full-history rule pinned AND asserts --recent is
  offered as the long-issue alternative.

Also fixes the recent+since cursor nit Elon flagged in #2787's second
review: when `since` empties the page, the `len(seenRoot) >= recentN`
check used to emit a cursor anyway. Pagination walks threads in
strictly decreasing last_activity_at — if every comment in this page is
<= since, every older thread's last_activity is also <= since by
transitivity, so the cursor would only invite the caller into a
guaranteed-empty walk. Now suppressed; new tests pin both branches
(suppressed when empty, retained when at least one row passes since).

MUL-2387

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): suppress recent+since cursor when head thread past since (MUL-2387)

Previous suppression only tripped when the `since` filter emptied the
page. That missed the mixed case Elon flagged in #2787's second review:
the page keeps rows from fresher threads but the head (oldest-active)
thread already sits at or before `since`, so every older page is
guaranteed empty too. Predicating on `headLast <= since` covers both
cases.

Add a recent=2 + since fixture that pins the mixed scenario: root1
(last_activity = base+3m) is filtered out, root2 stays, and the cursor
is suppressed even though the body is non-empty.

Co-authored-by: multica-agent <github@multica.ai>

* fix(prompt): clarify --recent is paging, not a replacement (MUL-2387)

Address Elon's second-pass nit on #2816: the assignment-trigger workflow
in runtime_config.go used "you may switch to --recent 20", which reads as
a replacement for the mandatory full-history rule. Rephrase --recent as a
paging strategy ("read the full history page-by-page, not a shortcut that
replaces it") so it cannot conflict with the rule it lives next to.

The default per-turn prompt in prompt.go opened with "If you need comment
history" — that soft conditional contradicts the runtime workflow's
mandatory read. Move it to a neutral "For comment history, follow the
rule in your runtime workflow file" framing that defers to whatever the
workflow says (mandatory for assignment, optional elsewhere) instead of
encoding its own policy.

Keep the runtime/prompt dual-layer fallback intact — different runtimes
propagate the config file vs. the per-turn user prompt with varying
fidelity, so both surfaces need the guidance.

Tests pin the new phrasing against regression:

- TestBuildPromptDefaultMentionsRecent now also forbids "If you need
  comment history" from sneaking back in.
- TestInjectRuntimeConfigAssignmentTriggerMentionsRecent now also forbids
  "you may switch to" / "switch to `--recent" replacement phrasing.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:42:35 +08:00
Naiyuan Qing
f5dbcad533 feat(mobile): settings — profile + notifications subscreens, RNR primitives, API helpers
Settings page rewritten to use RNR primitives (RadioGroup, Switch,
Avatar, Separator) instead of self-drawn equivalents, removes 3
hardcoded #71717a hex colors in favor of THEME tokens, and adds
Alert.alert confirmation on sign-out with destructive Button variant.

Two new push subscreens under more/settings/:
  - profile.tsx     edits name + avatar. Avatar tap opens iOS native
                    ActionSheetIOS (Take Photo / Library / Remove) via
                    expo-image-picker, then PATCH /api/me.
  - notifications.tsx  5 inbox groups + system_notifications toggle,
                       backed by optimistic PUT /api/notification-preferences.

New mobile-owned query + mutation for notification preferences mirror
the web design (no runtime import — per CLAUDE.md "Mobile-owned
updaters"). auth-store gets setUser action for in-memory user update
after profile PATCH.

ApiClient gains fetchValidated + fetchValidatedWith private helpers
that collapse the fetch+parseWithFallback envelope. 4 settings-related
methods migrated as canary (getMe, updateMe, getNotificationPreferences,
updateNotificationPreferences); remaining 30+ read methods migrate
progressively in later PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:34:43 +08:00
Naiyuan Qing
0de3d4012c refactor(mobile): typed ws.on<E>() + useWSSubscriptions to cut realtime boilerplate
Adds WSEventPayloadMap in @multica/core/types so callers get the precise
payload type per event — no more `const p = msg as IssueUpdatedPayload`
boilerplate at every handler. Mobile ws-client adopts the generic
signature; web's untyped on() is untouched but can opt in later.

useWSSubscriptions wraps the if-ws-and-wsId-then-useEffect-cleanup
template every Layer-3 realtime hook used to repeat. Each of the 8 hooks
sheds ~7 lines of lifecycle scaffolding and ~30 total `as Payload` casts
go away; only 1 deliberate cast stays for the cross-event onTaskEvent
(task:progress has no formal payload interface yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:34:23 +08:00
Jiayuan Zhang
ccd9e6cdfb feat(runtimes): simplify "Add a computer" dialog (MUL-2408) (#2839)
- Align Runtimes connect flow with Onboarding CLI install: install.sh + multica setup
- Drop manual "I've started the daemon" step; subscribe to daemon:register WS and auto-advance
- Rename Connect remote machine -> Add a computer, remove EC2-specific copy
- Rework UI per web design guidelines (focus rings, aria labels, live status, footer alignment)
- Fix DialogFooter negative-margin overflow with p-0 content; use outline Cancel button

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 08:33:32 +02:00
Jiayuan Zhang
8d30d76300 feat(dashboard): add 1d range to workspace Usage tab (#2837)
* feat(dashboard): add 1d time range to workspace Usage tab

1d means "today" — the natural calendar day from 00:00 UTC, matching the
rollup's bucket_date axis — not the trailing 24 hours. The client-side
dailyCutoffIso filter is now applied in daily dim too so 1d collapses
strictly to today even at the midnight UTC edge where the server's
wall-clock since cutoff would otherwise include yesterday.

Co-authored-by: multica-agent <github@multica.ai>

* fix(dashboard): scope `1d` to today only on aggregate endpoints

The pre-aggregated `byAgent` / `runTime` dashboard endpoints leaked
yesterday into the agent leaderboard and KPI cards for the `1d` time
range because `parseSinceParam(days=1)` returned `now-24h` (wall clock)
and the downstream SQL then applied `DATE_TRUNC('day', @since)`, which
landed on yesterday 00:00 UTC. The PR's client-side `dailyCutoffIso`
filter could only fix the date-bearing daily endpoints; aggregate
responses are already collapsed across dates.

Anchor `parseSinceParam` at UTC start-of-today instead, so `days=N`
covers N natural calendar days (today + N-1 prior). This matches the
frontend `dailyCutoffIso = today - (days-1)` semantic that the
workspace dashboard already assumes, and removes the off-by-one that
previously made `30d` return 31 buckets.

The runtime-detail page uses `parseSinceParamInTZ` (timezone-aware),
which is unchanged — it has no `1d` option.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 08:28:04 +02:00
Naiyuan Qing
abd9af96d3 fix(mobile): resolve mc:// image URIs against attachment list before render
Markdown content authored in Multica stores image references as
`mc://file/<id>` rather than baking signed HTTPS URLs into the text
(signed URLs expire). iOS image loader doesn't understand the `mc:`
scheme, so any attachment-image in a description, comment, or chat
message was raising a redbox: "No suitable image URL loader found for
mc://file/...".

Web already resolves this via `packages/views/editor/
attachment-download-context.tsx`: components look up the markdown URL
in the issue's attachment list and use the matching `download_url`.
This commit mirrors that pattern for mobile.

The wiring:

  - `data/schemas.ts` — AttachmentListSchema + EMPTY_ATTACHMENT_LIST
  - `data/api.ts` — listAttachments(issueId) → GET /api/issues/:id/attachments
  - `data/queries/issue-keys.ts` — `attachments(wsId, id)` key
  - `data/queries/issues.ts` — issueAttachmentsOptions
  - `lib/markdown/markdown.tsx` — Markdown accepts `attachments?` and
    forwards to MarkdownImage
  - `lib/markdown/markdown-image.tsx` — looks up uri in attachments,
    swaps for `download_url`; unresolved URIs fall through and fail
    the getSize callback gracefully (16:9 muted placeholder, no
    redbox)
  - `IssueDescription` and `CommentCard` — fetch via
    issueAttachmentsOptions; TanStack Query dedupes so the same
    issue's attachment list only fires one request regardless of how
    many components need it
  - `chat-message-list` — passes `message.attachments` directly (chat
    messages carry their attachment list on the message record itself,
    distinct from the issue-scoped model)

Unmatched URIs (e.g. test placeholders like `file_abc123`) now render
the same muted 16:9 fallback as a 404 — never a redbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:35:43 +08:00
Naiyuan Qing
125151e2c4 refactor(mobile): simple Header + IconButton, drop ScreenHeader / ChatHeader
Tab and stack screens were carrying two hand-rolled header components
(ScreenHeader, ChatHeader) that reimplemented enough of UINavigationBar
to ship the obvious bugs: hardcoded hex colors that didn't follow the
NativeWind dark scheme, no shared dark/light token wiring, no consistent
touch feedback for action buttons (Pressable + custom className per
call site).

This commit collapses both into one shared component family:

  - `components/ui/header.tsx` — slot-based (`title` / `center` / `left`
    / `right`) rendered in the screen's JSX. Self-handles the top safe
    area, uses semantic RNR tokens (`bg-background`, `text-foreground`,
    `border-border`) so dark mode flips via NativeWind class mode with
    no per-screen logic.
  - `components/ui/icon-button.tsx` — `<RNR Button variant="ghost"
    size="icon">` wrapping an Ionicon whose color falls back to
    `useTheme().colors.text` (the active navigation theme), so the
    glyph follows dark/light automatically without callers passing
    a color prop.
  - `components/chat/chat-title-button.tsx` + `chat-session-actions.tsx`
    — chat-specific slots that plug into the same Header (center +
    right) instead of the chat tab having its own complete header.

Call sites:
  - Inbox / My Issues / Chat / more/issues — drop `<ScreenHeader>` and
    `<ChatHeader>`, render `<Header ...>` at the top of the screen body
    with the appropriate slot contents.
  - HeaderActions — Search / New-Issue buttons swap raw Pressable for
    IconButton. The previously-added Menu button is removed (redundant
    with the "More" tab in the bottom bar).
  - more/issues — was rendering both the workspace stack's native
    header AND its own ScreenHeader inside the screen body, so the
    filter button now goes onto the stack header via
    `navigation.setOptions({ headerRight })` and the in-body header
    is gone.

Why the per-tab Stack approach (briefly explored) was abandoned:
react-navigation's native large title is the only thing that needed a
Stack per tab, and the product doesn't want collapse-on-scroll. With
that gone, every dynamic header content piece (Inbox's archive menu,
Chat's agent picker title) was forced through `navigation.setOptions`
in a useLayoutEffect — strictly more complexity than just rendering
the Header in JSX with state passed as props.

Net: 349 lines removed, 208 added. Two header components deleted; two
small primitives added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:35:23 +08:00
Jiayuan Zhang
0339de54e7 add web design guidelines skill (#2832) 2026-05-19 12:09:41 +08:00
Jiayuan Zhang
c577a29c10 feat(onboarding): v2 per-question questionnaire (source/role/use_case) (#2814)
* feat(onboarding): per-question v2 questionnaire (source/role/use_case)

Replaces the 3-questions-on-one-screen gate with three lightweight,
individually-skippable steps. New step order:

  welcome → source → role → use_case → workspace → runtime → agent → first_issue

- New v2 questionnaire schema: source/role/use_case + per-slot
  `*_skipped` markers. `team_size` removed.
- Click-to-advance card grid with lucide + emoji icons (RFC Option B).
- Skip is a footer text button; Other expands a free-text input.
- Recommendation table updated for new role × use_case vocabulary,
  with use_case-only fallback when role is skipped.
- DB migration v1 → v2 maps existing role/use_case answers and drops
  team_size; historical nulls stay null (not retroactively skipped).
- Re-entry treats skipped slots as fresh; analytics record kept in DB.
- onboarding_questionnaire_submitted event payload updated:
  source replaces team_size, per-slot skip booleans added.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): tighten question UX (Continue, layout, brand icons)

Address review feedback on Source/Role/Use-case:

- Replace auto-advance with an explicit Continue button so selections
  are reviewable. Continue is disabled until something is picked (and,
  for Other, until the free-text input is non-empty).
- Move Back/Skip/Continue inline under the option grid; drop the
  duplicate Back from the top header — the page now has a single,
  anchored action row.
- Swap the placeholder lucide marks for real brand SVGs on Source:
  Google, X, LinkedIn, YouTube, and an OpenAI mark for the AI-assistant
  option. Generic options stay on lucide.
- Replace the awkward expanded underline input on the Other card with
  an inline borderless input that swaps in for the label slot, so the
  Other state has the same height and weight as the other cards.

E2E smoke test updated to click Continue between question steps.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): unify step nav, rename Runtime step around "where agents run"

- Refactor the Source/Role/Use case questionnaire steps to use the same
  3-region chrome (header with Back + step indicator, scrolling main,
  sticky footer with Skip + Continue) that Workspace/Runtime/Agent
  already use, so the Back/Skip/Continue affordances stay in the same
  on-screen position across the whole flow.
- Reframe the Runtime step around the user-visible question — "Where
  will your agents run?" — instead of the internal "runtime" concept.
  The aside panel keeps the educational "What's a runtime?" copy for
  users who want to learn.
- Drop the hard-coded "Step 3 · Runtime" eyebrow on the web fork step:
  Runtime is now step 5 of 7 after the per-question split, and the
  step indicator already shows the correct count.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): tighten Skip/Continue spacing in step footer

Group Skip and Continue inside a sub-flex with gap-2 so they read as a
single action cluster on the right, while the status hint still anchors
left via mr-auto. Applied to both the questionnaire steps and the
runtime step so the footer layout stays consistent across onboarding.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): move Skip/Continue inline below form, drop sticky footer

The sticky bottom footer left a large dead zone between the form
content and the action buttons — most onboarding steps only fill the
top third of the viewport. Move the hint + Skip + Continue inline,
directly below the form/options grid, so the buttons sit where the eye
already is after picking an option.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): match Skip button size to Continue (size="lg")

Skip used the default button size (h-8) while Continue used size="lg"
(h-9), so the two adjacent action buttons rendered visibly different
heights. Promote Skip to size="lg" in step-question and
step-runtime-connect so they line up.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): reframe step 3 as 'connect a computer' / 'pick an agent runtime'

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): replace cloud waitlist with "Coming soon", reword CLI intro

- Web Step 3 cloud card: remove "Join waitlist" CTA + dialog and render a
  static "Coming soon" badge instead. Drops CloudWaitlistDialog, the
  cloud DialogState, waitlistSubmitted local state, and the
  onWaitlistSubmitted prop on StepPlatformFork (desktop's
  StepRuntimeConnect still owns its own waitlist path).
- Tighten cloud_subtitle to drop the "join the waitlist" half now that
  the action is gone.
- cli_install.intro: "AI coding tool" → "agent runtime", EN + zh-Hans.

Tests updated to match: asserts the Coming soon badge is non-actionable
and drops the four cloud-dialog scenarios (now unreachable).

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): refresh button, "agent runtime" wording, coming-soon card

Three fixes on the desktop Step 3 empty state per review:

1. Empty headline + hints now say "agent runtime", matching the
   picker-context terminology established earlier in this PR.
2. Add a Refresh button (header pill in Found, inline with the
   headline in Empty). Desktop wires it to restart the bundled
   daemon so a freshly-installed Claude/Codex/Cursor CLI is picked
   up — the daemon's PATH probe runs once at boot, so without a
   restart the install would only take effect on next launch.
3. "Use a cloud computer" loses the waitlist dialog and renders as
   a disabled "Coming soon" badge, aligning with the web fork.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): address review follow-ups (i18n, step-order, version, tests)

- runtime-aside-panel: point "Learn more" to /docs/install-agent-runtime,
  branching by language so zh users land on /docs/zh/...
- zh-Hans: unify Cloud "Coming soon" wording to "即将推出"; translate
  step_workspace.preview.more_meta ("and more" -> "等等")
- onboarding-flow: derive forward navigation from ONBOARDING_STEP_ORDER
  via advanceFrom(curr) so inserting/reordering a step only requires
  editing the canonical array; runtime → agent/first_issue branch keeps
  its bespoke routing with a comment explaining why
- onboarding handler: gate questionnaireAnswers.complete() on
  Version == 2 so a future schema bump can't be silently mis-counted
  against v2 funnel semantics
- add unit tests for step-source / step-role / step-use-case (option
  click, Skip patch, Other free-text) and step-question shell
  (canContinue + pendingOther state machine)

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): rename useCaseFallback to fallbackFromUseCase

ESLint's react-hooks/rules-of-hooks treats any function starting with
"use" as a React hook. The helper is a pure switch — give it a name
that doesn't trip the rule.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 05:35:18 +02:00
Naiyuan Qing
8996e821b9 wip(mobile): markdown rendering tweaks — incomplete
Checkpoint commit. Markdown rendering refactor is in progress and not
yet producing the full expected output; committing so it isn't lost
alongside the RNR migration in the same tree. Will be finished in a
follow-up before push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:09:20 +08:00
Naiyuan Qing
420230c62a feat(mobile): adopt react-native-reusables + class-mode dark mode
First wave of the RNR migration documented in apps/mobile/docs/
rnr-migration.md. The hand-written components/ui/ shell was producing a
steady stream of dark-mode and sheet-handling bugs; this commit
establishes the foundation that lets every subsequent screen pick up
RNR-shipped components and a real theme system instead.

Foundation (Phase 1):
- global.css + tailwind.config.js switch to shadcn neutral CSS variables
  (light + dark) under :root and .dark:root, with Multica custom tokens
  appended. tailwind utilities resolve to hsl(var(--...)).
- New lib/theme.ts mirrors the variables in TypeScript and exports
  NAV_THEME for React Navigation chrome.
- New lib/use-color-scheme.ts wraps NativeWind's useColorScheme with
  expo-secure-store persistence (preference key: theme-preference,
  values: light/dark/system).
- components.json registers shadcn CLI paths so `npx @rnr/cli add` writes
  to the expected aliases. metro.config.js gains inlineRem: 16.
- app/_layout.tsx wraps the tree in ThemeProvider(NAV_THEME[scheme]) and
  mounts <PortalHost /> for RNR dialogs.
- Settings → Appearance picker (three rows: Light / Dark / System,
  persisted) — the only product addition in this commit.

Component canary (Phase 2):
- button.tsx + text.tsx replaced by RNR's defaults via the CLI (uses
  TextClassContext to flow text variants from Button into nested Text).
- 11 button call sites updated to wrap children in <Text> (the RNR
  convention). The old `brand` variant had zero call sites and was
  dropped without follow-up.

Bottom navigation:
- (tabs)/_layout.tsx tried NativeTabs first but rolled back to JS Tabs:
  NativeTabs hard-codes canPreventDefault: false on tabPress events, so
  the "More tap opens a sheet without navigating" pattern was
  unreachable. The rolled-back layout uses useColorScheme + THEME to
  derive active/inactive tint, fixing the dark-mode "dim selected tab"
  bug.
- More tab intercepts tabPress and pushes /[workspace]/menu — a stack
  route registered with presentation: "formSheet" +
  sheetAllowedDetents: "fitToContents" so iOS sizes the sheet to the
  menu's intrinsic height (UIKit handles drag handle, swipe dismiss,
  blur backdrop).
- The formSheet route is named `menu.tsx` rather than `more.tsx` to
  avoid the URL collision with (tabs)/more.tsx — both files would
  otherwise resolve to /[workspace]/more because (tabs) is a transparent
  route group.
- components/nav/global-nav-menu.tsx refactored from a self-managed
  Modal into a plain ScrollView (no flex-1, so fitToContents can
  measure). Closes via router.dismiss() instead of an onClose prop.

Docs / rules:
- apps/mobile/CLAUDE.md adds two hard rules: "defaults first" and "iOS
  native > RNR > discuss" (the three-tier waterfall).
- apps/mobile/docs/rnr-migration.md captures the alternatives evaluated,
  the three-tier component classification, the phased rollout, and the
  pitfalls hit during this commit.

Out of scope for this wave (planned but not started):
- Tier A remaining primitives (input / card / text-field / textarea)
- Tier B sheets (the 18 hand-rolled Modal sheets — to be replaced one
  PR at a time with ActionSheetIOS / native pickers / RNR Dialog)
- Tier C domain UI internal-token upgrades

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:09:00 +08:00
Naiyuan Qing
434003d129 fix(my-issues): rename tab 3 label to include squads (MUL-2397) (#2830)
Tab 3's semantics were widened in #2829 to surface issues assigned to
either an owned agent OR a squad the user belongs to / leads. The label
still said "我的智能体" / "My Agents", which under-described the new
scope. Rename to "我的智能体和小队" / "My Agents and Squads" so the tab
title matches what it filters.

Locale-only change. Filter logic, SQL, and other tabs untouched.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 11:07:34 +08:00
Naiyuan Qing
93153d08b7 feat(my-issues): cover squad assignees via involves_user_id (MUL-2397) (#2829)
Re-introduces the `involves_user_id` filter on the issues list / open-list /
count / grouped paths, but with the semantics nailed down for the second time
around: tab 3 surfaces issues whose assignee is an *indirect* extension of the
user (owned agent, or a squad they're a human member of / lead via owned agent
/ have an owned agent inside) — and explicitly NOT direct member assignment,
which is tab 1's meaning.

- server/pkg/db/queries/issue.sql: 4-branch filter on ListIssues /
  ListOpenIssues / CountIssues. Each subquery clamps workspace_id because
  issue.assignee_id is polymorphic with no FK. Leader resolution reads
  squad.leader_id directly, not the squad_member copy row (squad.go ignores
  errors when seeding that copy, so it can be missing). FindActiveDuplicateIssue
  switched from positional $2/$3/$4 to named sqlc.arg() — pure hygiene so the
  generated struct field names don't drift when new nargs are added.
- server/internal/handler/issue.go: parse involves_user_id and plumb it into
  the three sqlc params; ListGroupedIssues (hand-written dynamic SQL) gets a
  mirrored 4-branch fragment, no shortcut.
- packages/core: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter /
  api.listIssues / api.listGroupedIssues all carry the new param through.
- packages/views/my-issues: tab 3 switches from client-side agent-fanout to
  involves_user_id=user.id. agentListOptions import and the myAgentIds memo
  go away.
- server/internal/handler/issue_involves_test.go: 13 integration tests cover
  every branch (positive + cross-workspace negatives) plus the critical
  ExcludesDirectMemberAssignee negative on BOTH the sqlc and the grouped paths,
  locking tab 3 ∩ tab 1 = ∅.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 10:37:38 +08:00
Jiayuan Zhang
35fc318d68 feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382) (#2822)
* feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382)

Adds a Weekly view to the runtime Usage chart alongside Daily and Hourly,
backed by `aggregateByWeek` on the existing 180-day daily cache (no new
endpoint). Weeks are ISO 8601 Mon–Sun; the in-progress week is rendered at
half opacity and tooltip-labelled "partial · N / 7 days".

Side effects called out in the RFC:

- `sliceWindow` now reads "today" in the runtime's IANA timezone, fixing a
  one-day drift at the window edge when the browser and runtime sit in
  different time zones.
- ActivityHeatmap rows are reordered Mon → Sun to match the rest of the
  Weekly aggregation; "today" is computed in runtime tz so the grid's
  trailing column lines up with the daily rows the backend buckets.

Dimension / period coupling: switching dimension resets the period to that
dimension's default when the active value isn't in its allowed set
(Hourly 7/30, Daily 7/30/90, Weekly 30/90/180).

Unit tests cover weekStart / addDays / tz-aware today, the sliceWindow
boundary, and aggregateByWeek's partial-week math.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): weekly chart shows trailing calendar weeks (MUL-2382)

aggregateByWeek built one bucket per week-with-data, and the caller
took the last N buckets. With sparse data — old populated weeks plus
empty stretches near today — the slice surfaced the old weeks instead
of the trailing in-window calendar weeks the user selected.

Now aggregateByWeek takes weekCount and emits exactly that many
trailing calendar weeks anchored at today's week in the runtime tz.
Buckets are pre-zeroed so empty in-range weeks render as empty bars;
rows outside the window are dropped.

Co-authored-by: multica-agent <github@multica.ai>

* feat(usage): drop Hourly dim + add Daily/Weekly to workspace dashboard (MUL-2382)

- Remove Hourly from the runtime usage WHEN-chart: segmented control is
  now Daily / Weekly. Drop the HourlyActivityChart component,
  aggregateCostByHour helper, byHour query subscription, and the
  when_tab_hourly i18n key.
- Add the same Daily / Weekly dimension toggle to the workspace-level
  Usage page (dashboard-page.tsx). Time-range linkage matches the runtime
  page: Daily allows 7/30/90 (default 30), Weekly allows 30/90/180
  (default 90); switching dimensions resets `days` when the current value
  isn't in the new dimension's set.
- Reuse `aggregateByWeek` from runtimes/utils for cost / tokens
  (signature relaxed to accept the wider DashboardUsageDaily shape).
  Add `aggregateWeeklyTime` / `aggregateWeeklyTasks` in dashboard/utils
  with identical pre-zeroed trailing-week semantics. Workspace dashboard
  uses the user-chosen timezone (existing TimezoneSelect) as the
  week-boundary tz; runtime page continues to use the runtime's IANA tz.
- New `WeeklyTimeChart` / `WeeklyTasksChart` mirror their daily
  counterparts plus partial-week half-opacity bars and rangeLabel
  tooltips, matching the existing Weekly cost / tokens charts.
- Tests: drop hourly-related setup; add weekly run-time / tasks coverage
  asserting pre-zeroed trailing buckets and the same MUL-2382 sparse
  window-scoping regression we caught on the runtime side.

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): correct workspace Weekly window + lock tz to UTC (MUL-2382)

Two blocking correctness bugs from Emacs's PR #2822 review:

1. The Weekly chart paints `ceil(days/7)` trailing calendar weeks but the
   API was still asked for exactly `days`. Worst case (today = Sunday on a
   30D request) the leftmost Monday sits 34 days back, so the first week's
   bucket was silently truncated. Over-fetch the per-date queries to
   `weekCount * 7` days when Weekly is active; per-agent rollups stay at
   `days` so the KPI / leaderboard labels keep their advertised window.
   Daily-aggregation surfaces (cost/tokens/time/tasks KPIs and the Daily
   chart) re-scope the over-fetched rows back to `days` so the labels
   stay consistent.

2. The backend dashboard rollup buckets data by UTC `bucket_date` (and the
   raw fallback queries by `DATE(tu.created_at)`, also UTC), but the
   frontend was driving Weekly boundaries from the user-chosen
   `TimezoneSelect`. Near midnight UTC that put cross-boundary rows into
   the wrong calendar week. Lock workspace Weekly to UTC and remove the
   timezone picker from this page; the runtime detail page keeps its own
   `runtime.timezone`-anchored aggregation, which is consistent because
   its rollup is materialized in that runtime's tz.

Verification: pnpm --filter @multica/views test (636 passed),
typecheck clean, lint 0 errors / 13 pre-existing warnings.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 04:24:46 +02:00
Naiyuan Qing
5476e7678d Revert "feat(my-issues): cover squad assignees via involves_user_id (MUL-2364…" (#2828)
This reverts commit 3c510c31ed.
2026-05-19 09:31:43 +08:00
Anderson Shindy Oki
e65c0889b9 feat: Add squad page responsive layout (#2826) 2026-05-19 09:18:30 +08:00
Naiyuan Qing
8db354f721 feat(editor): add open-in-new-tab to HTML attachment full-screen modal (#2827)
The inline HtmlAttachmentPreview toolbar carries an "Open in new tab"
button that routes to /{slug}/attachments/{id}/preview. The full-screen
AttachmentPreviewModal was missing the same affordance, so users who
maximized an HTML preview lost the ability to pop it into its own tab.

Mirror the gating exactly: show when kind === 'html' && slug &&
attachmentId. Other PreviewKinds keep the existing header (Download +
Close) — they don't have a corresponding full-page route.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 09:06:35 +08:00
Naiyuan Qing
3c510c31ed feat(my-issues): cover squad assignees via involves_user_id (MUL-2364) (#2801)
* feat(my-issues): cover squad assignees via involves_user_id (MUL-2364)

The "My Agents" tab on /my-issues only resolved agents owned by the
caller, so issues assigned to squads (member, leader, or agent-member of
mine) never surfaced. This added a UNION-based involves_user_id filter
that the backend expands to "me + agents I own + squads I relate to" in
a single query.

- SQL: ListIssues / ListOpenIssues / CountIssues accept narg
  involves_user_id and OR a workspace-scoped 3-branch UNION on the
  squad assignee subquery. Leader is sourced from canonical
  squad.leader_id (not the best-effort squad_member copy row whose
  AddSquadMember error is dropped in squad.go:177-188 and :259-263).
- Handler: parses involves_user_id via parseUUIDOrBadRequest, plumbs
  into all three list params, and mirrors the same UNION fragment into
  the grouped dynamic SQL path.
- Frontend: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter
  gain involves_user_id; api client forwards it to the querystring.
- My Issues page: "agents" scope now passes involves_user_id instead of
  fanning out owned-agent IDs client-side. Tab label widens to
  "我的智能体 / 小队" / "My Agents / Squads".
- Tests: Go suite covers all three squad relations including the
  canonical-leader-without-squad_member-copy variant, cross-workspace
  isolation for agent / leader / squad_member branches, combination
  with creator_id, and the malformed-UUID 400 path. Client test pins
  the involves_user_id querystring wiring for both list endpoints.

The FindActiveDuplicateIssue query gets explicit sqlc.arg() names so
sqlc regeneration keeps the existing struct field names regardless of
the local sqlc version (no behavior change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(my-issues): tighten cross-workspace negatives for involves_user_id UNION

Cross-workspace negative tests previously put both the foreign actor and the
foreign issue in the foreign workspace, so the outer i.workspace_id = $1
already excluded the row before the UNION branches were exercised. Stripping
a.workspace_id = $1 / s.workspace_id = $1 from any of the UNION subqueries
would not have failed the tests.

Rewrite the three existing negative cases to seed the issue in
testWorkspaceID with a polymorphic assignee_id pointing at a foreign-workspace
agent or squad (issue.assignee_id has no FK per migrations/001_init.up.sql:61).
Now each UNION branch must enforce its own workspace scoping for the issue to
stay out of the result.

Also add ExcludesOtherWorkspaceSquadAgentMember: the squad_member.agent UNION
branch had only positive coverage; this test pins that s.workspace_id = $1
and a.workspace_id = $1 must both hold there too.

Verified by mutation: stripping the workspace clause from each branch makes
the corresponding test fail.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 09:01:51 +08:00
Naiyuan Qing
ca9004961d Merge remote-tracking branch 'origin/main' into feat/mobile-ios
# Conflicts:
#	pnpm-lock.yaml
2026-05-19 08:37:02 +08:00
Jiayuan Zhang
54f884ebc8 docs(runtimes): add install-agent-runtime page and link from onboarding empty state (#2825)
New docs page covering install pointers, binary names the daemon scans
for, and basic auth notes for all 11 supported AI coding tools. EN +
zh-Hans, registered under "How agents run" in the docs sidebar.

The onboarding "no agent runtime found" empty state now shows an
"Install an agent runtime →" link that opens the new doc, so users have
a discoverable path beyond "skip" and "join waitlist".

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 02:00:31 +08:00
Jiayuan Zhang
e0a6a39a47 feat(agents): list-only tasks panel with issue search (MUL-2391) (#2820)
* feat(agents): list-only tasks panel with issue search (MUL-2391)

Replace the agent detail tasks view-mode toggle with a fixed list view and
add a search bar that filters by issue title, identifier, or pinyin.

Co-authored-by: multica-agent <github@multica.ai>

* fix(actor-issues): only show search empty state when searching

Previously the panel rendered the search empty state whenever the
filtered issue list was empty, which masked ListView's own status-based
empty states when status/priority/assignee/project/label filters
narrowed the list to 0. Now search_empty only renders when
`search.trim()` is non-empty and results are 0; otherwise ListView
takes over and shows its native empty states.

Refs MUL-2391

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:44:21 +02:00
Bohan Jiang
6f5fbb7813 feat(comments): thread-aware list with composite cursor (MUL-2340) (#2787)
* feat(comments): thread-aware list with composite cursor (MUL-2340)

Adds three optional query params to GET /api/issues/{id}/comments and the
matching `multica issue comment list` flags:

- `thread=<comment-uuid>` resolves the anchor to the thread root via a
  recursive CTE (defends against any future nested replies) and returns
  root + all descendants chronologically. Anchor can be any comment in
  the thread, root or reply.
- `recent=<N>` returns the newest N comments for the issue, ordered
  chronologically in the response.
- `before=<RFC3339>` + `before-id=<uuid>` form a composite cursor for
  stable pagination of `recent`. Both must be set together; a
  timestamp-only cursor is rejected because ties on `created_at` would
  let the existing `(created_at ASC, id ASC)` total order skip or
  duplicate rows across pages.

Flag combination rules: `thread` is exclusive with `recent` and the
cursor; both may combine with `since`. Server and CLI enforce the same
matrix; the CLI fails fast locally so callers don't pay for a 400
round-trip.

Default behaviour (no params) is unchanged — full chronological dump
capped at commentHardCap — so the desktop UI and existing `--since`
polling are untouched. Agent prompt updates land in a follow-up PR so
the new CLI capabilities ship and bake first.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): reject cursor without recent and align CLI/server on invalid --recent (MUL-2340)

Elon's PR #2787 second review flagged two gaps in the flag combination
matrix:

- server: GET /comments?before=...&before_id=... without `recent` was
  silently dropped by fetchCommentsForList (RecentN=0 fell through to
  the default / since path), so callers got the full timeline instead
  of the documented "before X" semantics. Now returns 400.
- CLI: --recent 0 / --recent -3 were collapsed with "flag not passed"
  by `recent > 0`, so an explicit invalid value silently fell back to
  the default list. Switched to Flags().Changed("recent") so explicit
  non-positive values fail loudly. Also enforces that --before /
  --before-id only appear with explicit --recent (mirrors the new
  server-side rule).

Tests:
- server flag matrix gains `before + before_id without recent → 400`.
- CLI gains TestRunIssueCommentListFlagGuards covering `--recent 0`,
  `--recent -3`, cursor-without-recent, and the thread/recent
  exclusivity path under the new Changed()-based check. The mock
  server fatals if a request reaches /comments, proving the guards
  fire before any HTTP round-trip.

Co-authored-by: multica-agent <github@multica.ai>

* feat(comments): make `recent` thread-grouped with a thread cursor (MUL-2340)

Bohan pushed back on the row-based `recent=N` shape: comments form a tree,
not a list, and the newest N rows can come from N unrelated threads, giving
the agent N disjoint conversational tails. Replace the row-based query with
a thread-grouped one before #2787 merges so we never ship the wrong shape:

- `recent=N` now returns the N most recently active threads (root + every
  descendant per thread). A thread's recency is MAX(created_at) across its
  whole subtree, so a stale-but-recently-replied thread outranks an old
  quiet one — exactly the property row-recent loses.
- The cursor is now a *thread* cursor: `before` = a thread's
  last_activity_at, `before_id` = its root comment id. The pair walks
  threads strictly less recent than the page's oldest-active thread. The
  cursor surfaces via `X-Multica-Next-Before` / `X-Multica-Next-Before-Id`
  response headers (empty when there are no older threads); the CLI
  forwards the same pair to stderr after listing.
- Row-based `recent` is gone — there is no internal caller and the prompt
  update has not shipped yet, so there is no compat surface to preserve.
- Response body shape unchanged (flat JSON array, chronological). Default
  and `--since` paths untouched. Desktop UI keeps working.

Tests:
- recent=1 returns the freshest-active thread fully; recent=2 returns both
  with the older-active thread first (oldest-active → freshest tail).
- Stale-but-fresh: a thread whose root is older but has a fresh reply
  outranks a thread whose root is newer but quiet.
- Cursor headers emitted only on full pages; empty on the final page.
- Pagination walks threads root2 → root1 → empty, no skips/duplicates.
- Tie-break: three threads sharing last_activity_at paginate one-at-a-time
  using (last_activity_at, root_id) ordering — verifies the timestamp-only
  cursor failure mode is fixed for the thread case too.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 19:28:26 +08:00
Naiyuan Qing
baedc48f59 fix(editor): source-view highlight + HTML attachment open-in-new-tab (#2812)
* fix(editor): bump hast-util-to-html to v9 so lowlight output actually serializes

Source view of fenced ```html (and any other code block falling through to
the lowlight branch in ReadonlyContent) silently rendered as un-highlighted
escaped text. Root cause was a stale dep pin: `hast-util-to-html: ^4.0.1`
predates the package's ESM/named-export rewrite — v4 only exports a CJS
default function, so the `import { toHtml } from "hast-util-to-html"` in
code-block-static.tsx:19 and readonly-content.tsx:32 resolved to
`undefined` at runtime. The try/catch in both call sites caught the
"toHtml is not a function" throw and fell through to escapeHtml plain
text, so no `.hljs-*` spans ever made it to the DOM and the syntax-color
CSS added in #2808 had nothing to attach to.

Bumping to ^9.0.5 (matches the v9 line that lowlight@3 / remark / rehype
ship in the rest of the tree) makes the named `toHtml` export available
and source-view highlighting works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): open HTML attachment in new tab + full-page preview route

Adds a third toolbar button to HtmlAttachmentPreview between Maximize and
Download: open the attachment in a new app tab (desktop) or browser tab
(web). The full-screen modal stays — they serve different scenarios:
modal for a quick "see it bigger" without leaving the issue context,
new-tab when the user wants to keep the rendered HTML around while
working on something else.

Components:
- New workspace path: `/{slug}/attachments/{id}/preview?name={filename}`.
  Lives outside the (dashboard) group on web so the iframe gets the full
  viewport — sidebar would defeat the point. Desktop registers the route
  inside `WorkspaceRouteLayout` so workspace context resolution still
  runs (no slug → no path is built).
- `packages/views/attachments/attachment-preview-page.tsx`: shared full-
  page view that reuses `useAttachmentHtmlText` for the iframe srcDoc.
  Sandbox stays `allow-scripts` (no allow-same-origin) — same security
  posture as the inline preview.
- `HtmlAttachmentPreview`: adds Open-in-new-tab button. Routes through
  `useNavigation().openInNewTab` when available (desktop), falls back to
  `window.open(getShareableUrl(path))` on web. Button is hidden when no
  workspace slug is in scope (shouldn't happen in practice, but the
  shared component must not throw outside a workspace route).

Tests cover: desktop openInNewTab call args, web window.open fallback,
and that the failure-mode toolbar still surfaces all three actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): drop now-stale @ts-expect-error on hast-util-to-html imports

v9 ships bundled type declarations, so the directives added for v4 trigger
TS2578 ("Unused '@ts-expect-error' directive") on CI typecheck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:09:45 +08:00
Boynn
933f417dac fix(views): clear manual draft when packing into agent prompt (#2370)
When alternately switching between manual and agent modes in the create-issue
dialog, the title and description were being duplicated and accumulated on
every round-trip. Root cause: manual→agent packed title+description into the
agent prompt but left them in the shared useIssueDraftStore; the subsequent
agent→manual wrote the agent markdown into draft.description while the stale
draft.title persisted, so the remounted manual panel surfaced both.

Clear title/description from the shared draft at the moment they move into
the agent representation, so round-trips can't layer stale manual state on
top of prompt-as-description.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:50:08 +08:00
Naiyuan Qing
e6cf5a6eca fix(editor): highlight HTML source view + drop misplaced Copy on attachments (#2808)
Two issues from #2790's HTML inline preview work:

1. HTML source view rendered as default-colored text. lowlight emits
   `.hljs-tag` / `.hljs-name` for `<...>` brackets and element names, but
   content-editor.css only styled the keyword / string / attr / etc.
   classes — so toggling an inline ```html``` block to "source" showed
   attributes colored and everything else plain. Adds the two missing
   classes in light + dark.

2. HtmlAttachmentPreview carried a "Copy code" button. An HTML attachment
   is a file (view + download), not an inline source snippet. The inline
   ```html``` fenced block (HtmlBlockPreview) is where reading / copying
   source belongs. Drops the button, its state, and the useAttachmentHtmlText
   `canCopy` branch — the hook is still needed for the iframe srcDoc.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:27:28 +08:00
Qi Yijiazhen
d9ae891064 fix(avatar): stop bg-muted bleeding through transparent images (#2670)
ActorAvatar applies bg-muted on its container regardless of whether
an image is loaded, so transparent regions of PNG/SVG avatars reveal
the grey placeholder. agent-detail-inspector also wraps ActorAvatar
in an outer bg-muted div, layering a second grey square.

Make bg-muted conditional on the fallback state in ActorAvatar, and
drop the redundant bg-muted from avatar-picker's image-loaded branch
and the two inspector wrappers. Empty-state placeholders unchanged.
2026-05-18 18:23:46 +08:00
Bohan Jiang
ffba2607aa fix(daemon): default auto-update off for self-host instances (MUL-2381) (#2807)
A self-host operator running a fork of Multica with their own patches would
have their daemon silently upgraded to the upstream GitHub release, clobbering
the fork. Self-host setups also routinely pin to an older server, so a fresh
CLI may no longer talk to it.

Flip the default: auto-update remains opt-in on api.multica.ai and defaults to
off on any other server URL. Either side can override via
MULTICA_DAEMON_AUTO_UPDATE.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:19:07 +08:00
Bohan Jiang
b97cc3cb6e fix(autopilots): align trash icon with action buttons in webhook trigger row (#2805)
The TriggerRow's outer flex uses `items-start`, which made sense back
when every trigger only had one row of content (label + maybe a cron
expression). Once #2774 added the URL action row to webhook triggers
(Copy + Rotate buttons sitting on a second line inside the inner column),
the trash button stayed pinned to the top-right of the outer flex — it
visibly floats above the URL action buttons instead of lining up with
them, which reads as a layout glitch.

Move the trash button into the URL action row for webhook triggers so
all three action buttons (Copy, Rotate, Delete) share one flex container
and align by construction. Schedule and API triggers — which have no
URL row — keep the trash button pinned top-right (their bodies are
short enough that the top corner reads as "the row's right end").

Extract a `deleteButton` const so the JSX isn't duplicated, and add the
existing `delete_dialog.confirm` i18n string as the title attribute for
consistency with the other action buttons (Copy / Rotate already have
hover titles).

No behavioural change — same click handler, same confirm dialog.
2026-05-18 18:16:45 +08:00
Multica Eve
b58ab2cc48 docs: remove reverted runtime changelog note (#2806)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:44 +08:00
Bohan Jiang
eabfb8f3d1 fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370) (#2799)
* fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370)

`--issue-title-template` (and the matching `issue_title_template` API
field) silently kept any placeholder other than `{{date}}` as a literal
string in the rendered issue title — `{{.TriggeredAt}}`, `{{trigger_id}}`,
`${date}`, etc. would all slip through `strings.ReplaceAll` unchanged
because the renderer only knew one token. The flag name and help text
("Template for issue titles (create_issue mode)") and the docs phrasing
("the title supports interpolation like `{{date}}`") both implied a
richer placeholder set existed.

Tightens the contract on three fronts:
- Reject any `{{...}}` token other than `{{date}}` at create/update time
  with `unknown template variable %q; supported: {{date}}` — turns the
  silent-on-trigger surprise into an explicit 400 the moment the user
  sets the template.
- Update CLI flag help on `autopilot create --issue-title-template` and
  `autopilot update --issue-title-template` to spell out that only
  `{{date}}` (UTC, YYYY-MM-DD) is interpolated.
- Update `apps/docs/content/docs/autopilots{,.zh}.mdx` to drop the
  "like `{{date}}`" phrasing for the single supported placeholder.

Adds service-layer tests covering `interpolateTemplate` (substitution,
empty-template fallback, no-placeholder verbatim) and
`ValidateIssueTitleTemplate` (accepts empty / plain / `{{date}}` /
`{{ date }}`; rejects Go-template, Mustache-style, future placeholders
like `{{datetime}}`, and templates that mix one valid and one invalid
token).

Expanding the placeholder set (`{{datetime}}`, `{{trigger_id}}`,
`{{trigger_source}}`) is tracked as a separate enhancement — those
need run/trigger context plumbed into the renderer, which is out of
scope for this bug fix.

Closes #2732

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilots): render {{ date }} whitespace form too (MUL-2370)

Validator permitted {{ date }} but interpolateTemplate only matched the
exact string {{date}}, so a template that passed create/update could
still emit a literal {{ date }} at trigger time — re-introducing the
silent-literal behaviour the validator was meant to remove.

Route rendering through the same regex as validation so every accepted
form is also a substituted form. Cover {{ date }} substitution in
TestInterpolateTemplate.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:14 +08:00
Bohan Jiang
e8d4b9a0a2 revert: drop exec_command watchdog (#2779, #2786) (MUL-2337) (#2803)
* Revert "fix(codex): bump default exec_command stuck timeout to 3 minutes (#2786)"

This reverts commit 433cd1aaf5.

Co-authored-by: multica-agent <github@multica.ai>

* Revert "feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337) (#2779)"

This reverts commit 60bae62622.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:08:07 +08:00
Bohan Jiang
fe1ccb19c9 Revert "MUL-2324 conditionally inject non-core rule blocks (#2771)" (#2802)
This reverts commit e8fb0efe3d.
2026-05-18 17:48:44 +08:00
Naiyuan Qing
5f1ced867c feat(editor): HTML attachments render like images (MUL-2345 v4) (#2798)
* feat(editor): HTML attachments render like images (MUL-2345 v4)

HTML attachments no longer wear the file-card chrome (icon + filename
row). They now render as a sandboxed iframe with a hover-revealed
right-top toolbar (Open / Download / Copy code), mirroring the image
attachment visual model.

- New HtmlAttachmentPreview owns the iframe + hover toolbar plus three
  states (loading / success / error). Failure mode keeps the toolbar
  pinned open and Open/Download enabled so the user is never stranded
  without an escape hatch — Copy code disables when the text body is
  unavailable.
- New AttachmentBlock thin dispatcher picks the renderer per kind:
  html + attachmentId + !uploading -> HtmlAttachmentPreview, else
  AttachmentCard. All three entry points (file-card NodeView, readonly
  file-card, standalone AttachmentList) call AttachmentBlock, so feature
  work on a new kind only touches one place.
- AttachmentCard collapses back to a pure file-card row UI: the inline
  HTML iframe branch (InlineHtmlIframe + inlineHtmlEnabled +
  showInlineHtml) is removed.
- AttachmentBlock added to the editor barrel export.

Sandbox/server-side defenses unchanged: sandbox="allow-scripts" (no
allow-same-origin), srcDoc, server still returns text/plain + nosniff
on the /content proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(editor): pin three entry points to AttachmentBlock HTML route (MUL-2345)

Reviewer flagged that the v4 dispatcher refactor only had tests on the
shared AttachmentBlock + HtmlAttachmentPreview; the three real call
sites at file-card.tsx:59, readonly-content.tsx:279, and
comment-card.tsx:152 had no regression coverage. Reverting any one
would silently lose the inline HTML iframe path — the exact MUL-2330
regression we're meant to be locking down.

Each new test renders the real entry point with an HTML+attachmentId
fixture and asserts the dispatched iframe (sandbox=allow-scripts,
srcdoc) shows up while the AttachmentCard chrome (filename row) does
not. FileCardView and AttachmentList are exported from their files for
direct rendering, mirroring the existing CodeBlockView test pattern.

Mutation-tested locally: temporarily flipping each site back to
<AttachmentCard> turns its corresponding test red.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:44:32 +08:00
Multica Eve
4d8b6ddb84 docs: add May 18 changelog entry (#2800)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:28:52 +08:00
Bohan Jiang
692570f41a fix(autopilots): contain Delivery dialog within viewport (#2788)
Two related overflow bugs in the Delivery detail dialog (the popover you
open from a webhook deliveries row, shipped in #2784) became obvious as
soon as a real webhook payload was exercised:

1. **Horizontal overflow: minified JSON pushed dialog off-screen.**
   `CodeBlock`'s `<pre>` uses `white-space: pre` (default for the tag),
   which means a single-line minified JSON body had intrinsic
   min-content equal to the whole line's width. The parent grid cell
   inherits the default `min-width: auto` (= min-content), so a long
   body propagated all the way up and blew DialogContent past its
   `max-w-2xl` cap. Headers rendered fine because they're
   pretty-printed JSON with real newlines.

   Fix: `min-w-0` on the CodeBlock wrapper so it can shrink below
   min-content, plus `whitespace-pre-wrap break-all` on the `<pre>` so
   long lines wrap (`break-all` is the only modifier that breaks
   mid-token, which a minified JSON body needs because it has no
   whitespace to break at).

2. **Vertical overflow: dialog grew past viewport.**
   `DialogContent` had no height cap. With Raw body + Headers +
   Response body + Replay button stacked vertically, anything beyond
   the screen edge (notably the Replay button) became unreachable.

   Fix: `max-h-[85vh] overflow-y-auto` on `DialogContent`.

Both fixes are CSS-only in one file; HMR verified.
2026-05-18 17:07:14 +08:00
Bohan Jiang
84d75cdd1e docs(self-host): reverse-proxy guidance for loopback-only ports (MUL-2360) (#2794)
* docs(self-host): explain loopback-only bindings + reverse proxy guidance (MUL-2360)

Follow-up to #2759, which bound all docker-compose published ports to
127.0.0.1. The self-host quickstart still told cross-machine users to
point their CLI at `http://<server-ip>:8080`, which no longer works
(and shouldn't — the default JWT_SECRET/Postgres creds must not be
reachable from the open internet).

- Add a Callout to step 1 explaining the loopback-only bindings and
  linking to the new reverse-proxy step.
- Split step 5 into 5a (same machine, defaults) and 5b (cross-machine),
  with a minimal Caddyfile that fronts both frontend and backend on a
  single hostname (including the `/ws` route with `flush_interval -1`).
  Switch the cross-machine `--server-url` example to `https://<domain>`.
- Mirror the changes in the Chinese quickstart.
- Add a header comment block to docker-compose.selfhost.yml so anyone
  reading the file directly understands why services don't show up on
  `0.0.0.0` and what to do about it.

Co-authored-by: multica-agent <github@multica.ai>

* docs(self-host): use nginx highlighter for Caddyfile snippet

Shiki's default bundle does not include `caddy` / `caddyfile`, so
Vercel's `pnpm build` failed with:

  ShikiError: Language `caddy` is not included in this bundle.

Switch the code fence to `nginx`, which is in the default bundle and
gives near-identical visual highlighting for this snippet. No content
changes — the Caddyfile inside the block is untouched.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:00:31 +08:00
AdamQQQ
fab0671332 feat(skills): support multi-select bulk import in Copy from runtime (#2686)
- Multi-select UI for batch importing skills from a local runtime
- Server batch-dispatches up to 10 import requests per heartbeat cycle
- WS heartbeat now reads supports_batch_import from daemon payload
  instead of hardcoding true, so old daemons correctly fall back to
  one-at-a-time dispatch
- Raised server pending timeout to 3min and client poll timeout to 4min
  to accommodate daemons that pop only one import per 15s heartbeat

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 16:56:27 +08:00
Jiayuan Zhang
46c1e2c889 feat(squads): show member working status on squad detail page (#2768)
* feat(squads): show member working status on squad detail page

Add a new GET /api/squads/{id}/members/status endpoint that returns each
member's derived working/idle/offline/unstable status, the issues each
agent is currently running, and the last observed activity timestamp.
The Squad detail page's Members tab consumes this snapshot to render a
status pill and an active-issue link next to each agent, with live
refresh wired through the existing task/agent/daemon WS events.

Human members are returned with status=null so the UI can keep them in
the same list without implying a presence signal. Archived agents stay
in the response and surface as offline rather than being filtered out.

Co-authored-by: multica-agent <github@multica.ai>

* fix(squads): address review feedback on member status endpoint

- i18n the "blocked" issue-status pill in squad members tab (was a
  bare literal that failed `i18next/no-literal-string` lint).
- Treat any dispatched/running task as working, even when its
  `agent_task_queue.issue_id` is NULL (chat / quick-create tasks).
  The agent slot is occupied regardless of whether we can render an
  issue link.
- Force `offline` for archived agents so they appear in the list
  but never look like they're still on duty, matching the RFC
  decision in MUL-2319.
- Include `workspaceKeys.squads` in the post-reconnect /
  workspace-switch bulk invalidation so members-status recovers
  after a disconnect during which task/runtime events were missed.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 10:35:18 +02:00
Zheng Li
c78bfbcf17 fix(skills): keep skill title input transparent in dark mode (#2710)
The skill name Input on the detail editor uses `bg-transparent px-0`
to render as flush, chrome-less text. The base Input component also
applies `dark:bg-input/30`, which Tailwind keeps because it lives in
the `dark:` variant. In dark mode this exposes a 30% white fill that
appears flush against the text — looking like missing left padding.

Add `dark:bg-transparent` to the className so the override wins in
both color modes.
2026-05-18 16:32:28 +08:00
Bohan Jiang
1796ef6dff fix(runtimes): prefer Local machine as default selection (MUL-2359) (#2792)
On desktop, localDaemonId is fetched async, so on first paint the only
machines available are remotes — the existing auto-select picks the
first remote, then sticks because subsequent renders see selectedMachineId
still in the list. Result: the local Mac never gets the default focus
even though it sorts first.

Re-evaluate the default on every machines change, preferring the local
section. Honor a user pick once it's been made.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:29:02 +08:00
Naiyuan Qing
ceb967aefa feat(editor): inline HTML attachment preview + ```html block render (MUL-2345) (#2790)
* feat(editor): inline HTML attachment preview + ```html block render (MUL-2345)

* attachment-preview-modal: switch HTML iframe sandbox from "" to
  "allow-scripts" so JS-driven chart libraries render. The opaque-origin
  iframe still cannot touch cookies, localStorage, parent state, or
  top-nav — only scripts run.
* New shared AttachmentCard wired into the three attachment surfaces
  (file-card NodeView, ReadonlyContent file-card branch, comment-card
  standalone AttachmentList). HTML attachments now render inline via a
  sandboxed iframe pulled through the existing /content proxy; other
  kinds keep the original chrome behavior.
* New HtmlBlockPreview for fenced ```html blocks in ReadonlyContent —
  default preview iframe, source/Copy toggle. Two-layer code+pre unwrap
  mirrors the Mermaid pattern; unwrap now matches on language-* class
  because react-markdown invokes pre before the code renderer runs.
* CodeBlockView (Tiptap NodeView) renders an iframe preview for
  language=html with a CSS-hidden toggle to the editable source — the
  <NodeViewContent as="code"/> mount must remain in the tree.
* Shared use-attachment-html-text hook keeps inline and modal HTML
  rendering on the same React Query cache.
* Vitest coverage: allow-scripts assertion, attachment-card kind
  branches, readonly HTML iframe + Mermaid unwrap regression, NodeView
  editable + preview/source toggle.

No backend changes; server-side text/plain + nosniff defense kept.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): tighten attachment preview and pre unwrap gates (MUL-2345)

Addresses Reviewer REQUEST CHANGES on PR #2790:

1. URL-only text/html attachment cards no longer surface a dead Eye
   button. `AttachmentCard` previously allowed preview when
   `previewableFromUrl=true` regardless of kind, but the modal's
   `tryOpen` rejects URL-only text kinds because the `/content` proxy
   is ID-keyed. Drop the `previewableFromUrl` prop and gate the
   no-attachmentId path strictly to URL-previewable media kinds
   (pdf/video/audio).

2. Readonly `pre` unwrap now uses exact class-token matching. The
   previous `className.includes("language-html")` check also fired
   on `language-htmlbars`, silently stripping its `<pre>` wrapper.
   Use `/(^|\s)language-(html|mermaid)(\s|$)/` so only the exact
   tokens unwrap.

Regression tests:
- `report.html + no attachmentId` asserts no Preview button.
- `pdf URL-only` asserts Preview button still appears.
- `htmlbars` / `mermaidx` fences keep their `<pre><code>` wrapper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:23:40 +08:00
Ayman Alkurdi
d04b00b32e fix(security): bind all services to loopback in docker-compose files (#2759)
The base docker-compose.yml bound postgres to 0.0.0.0:5432 and
docker-compose.selfhost.yml bound postgres/backend/frontend without
a host_ip prefix — defaulting to 0.0.0.0 on all interfaces.

On any VPS with a public IP, these services were reachable from the
internet. Docker bypasses UFW iptables chains by default, so host-
level firewall rules on these ports had no effect.

Fix: prefix every port binding with 127.0.0.1 so services are only
reachable from the host itself. This matches the documented
DATABASE_URL (which uses localhost) and does not break any legitimate
local dev or self-host workflow — connections from the host shell,
migration scripts, and the backend container (via Docker internal
network) all continue to work unchanged.
2026-05-18 16:14:41 +08:00
Bohan Jiang
a4a18605eb fix(desktop): handle Cmd/Ctrl +/-/0 zoom in main process (MUL-2354) (#2791)
The default Electron application menu's zoomIn/zoomOut roles do not fire
reliably on macOS — Cmd+= would zoom in but Cmd+- could not undo it, so
users got stuck at the zoomed-in level with no way back.

Move the shortcut into before-input-event so the same handler covers
every platform and every keyboard layout. preventDefault here blocks
both the renderer keydown and the menu accelerator, so there's no
double-zoom risk on macOS.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:12:03 +08:00
Multica Eve
dfe2a57361 fix(autopilots): allow duplicate create_issue runs (#2789)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:05:54 +08:00
LinYushen
6621231237 fix: improve search ranking and snippet support (MUL-2329)
Fixes MUL-2329
2026-05-18 15:45:06 +08:00
Bohan Jiang
433cd1aaf5 fix(codex): bump default exec_command stuck timeout to 3 minutes (#2786)
The watchdog fires on a "no progress" window, so the default mainly
matters for commands that go fully silent (no outputDelta). Bumping
from 2m → 3m leaves more headroom for legitimately slow silent
commands before treating them as a dropped function_call_output, at
a modest cost to recovery latency.

MUL-2337

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:30:05 +08:00
YYClaw
8cc48b1176 fix(ui): vertically center SelectItem content (#2782) 2026-05-18 15:28:00 +08:00
Anderson Shindy Oki
2d501322e9 fix: Squads page unable to scroll (#2764) 2026-05-18 15:19:16 +08:00
Bohan Jiang
60bae62622 feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337) (#2779)
* feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337)

Codex app-server can drop the second function_call_output when two
exec_command calls fan out in the same turn and both async-yield through
the yield_time_ms boundary (observed 2026-05-18, MUL-2334 — Trump Agent
wedged for 6+ min with no semantic activity events to drive any existing
timer). The model then waits forever for the missing output; only the
10-minute semantic inactivity timeout would eventually rescue the run.

Add a per-call watchdog in the codex client that tracks open
exec_command / commandExecution items by call_id and fails the turn
quickly (default 2 min, configurable via ExecOptions.ExecCommandStuckTimeout)
when one stays open without progress. outputDelta events reset the
per-call progress timestamp so long-running streaming commands aren't
flagged.

This is a daemon-side mitigation only — codex itself still has the
upstream race, but the daemon no longer burns the full inactivity budget
before the run is marked failed and a new run can recover.

Co-authored-by: multica-agent <github@multica.ai>

* feat(codex): track legacy exec_command_output_delta in watchdog (MUL-2337)

Mirrors the raw v2 item/commandExecution/outputDelta refresh on the legacy
codex/event protocol so a long-running streaming exec doesn't get falsely
flagged as stuck after begin + 2 min.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:14:45 +08:00
Bohan Jiang
c328c402d8 feat(autopilots): webhook deliveries tab + replay button (MUL-2334) (#2784)
Wires the frontend onto the PR1 webhook delivery layer. Adds a Deliveries
section to the autopilot detail page that lists recent deliveries
(queued / dispatched / rejected / ignored / failed) with provider, event,
attempt count, and timestamp. Clicking a row opens a detail dialog with
raw body, headers subset, response body, signature status, and a Replay
button. Replay is disabled client-side for signature-invalid / rejected /
still-queued deliveries to mirror the server's 400.

Backend contract is locked behind a lenient zod schema via
parseWithFallback — unknown future status / signature_status values
degrade to a generic row instead of dropping the whole list.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:13:07 +08:00
Bohan Jiang
2323b72710 feat(autopilots): webhook delivery layer + idempotency/signature/replay (MUL-2334) [PR1] (#2774)
* feat(autopilots): webhook delivery layer + idempotency / signature / replay (MUL-2334)

Splits "inbound webhook receipt" from "autopilot run creation" so we can
record duplicate attempts, signature outcomes, and ignored/skipped
deliveries — and replay a delivery on demand. v1 ingress wrote straight
into autopilot_run.trigger_payload, which collapsed the two concerns and
left run_only autopilots vulnerable to provider retry storms.

Backend only (PR1). UI Deliveries tab follows in PR2.

Schema (migration 093):
  - autopilot_trigger.provider: 'generic' | 'github' (default 'generic').
  - autopilot_trigger.signing_secret: nullable plaintext (HMAC needs it
    cleartext; mirrors how webhook_token is stored).
  - webhook_delivery: one row per inbound POST. Carries raw_body,
    selected_headers, dedupe_key/source, signature_status,
    autopilot_run_id, replayed_from_delivery_id, response_status / body.
  - Partial unique index on (trigger_id, dedupe_key) excludes NULL and
    'rejected' rows, so a wrong-secret 401 does NOT permanently block a
    future retry with the same X-GitHub-Delivery once the operator fixes
    the secret.

Ingress flow (autopilot_webhook.go), persist-first + sync dispatch:
  1. IP rate limit -> 2. token lookup -> 3. token rate limit ->
  4. read raw body -> 5. autopilot/workspace cross-check ->
  6. normalize JSON (400 without persistence on parse failure) ->
  7. compute dedupe key + signature status ->
  8. INSERT delivery (status=queued). On (trigger_id, dedupe_key)
     unique-violation: bump attempt_count on existing row and return
     the original delivery_id + autopilot_run_id with 200 ->
  9. invalid/missing signature: UPDATE -> rejected, return 401 with
     delivery_id (no dispatch, not replayable) ->
 10. trigger disabled / autopilot paused/archived: UPDATE -> ignored,
     return 200 ->
 11. DispatchAutopilot synchronously, UPDATE -> dispatched/skipped/failed
     with autopilot_run_id and the response body we returned ->
 12. TouchAutopilotTriggerFiredAt and return 200.

No new long-running worker. A stale 'queued' row only happens if the
process dies between INSERT and UPDATE; that's a follow-up sweeper, not
this PR.

Authenticated API:
  - GET    /api/autopilots/{id}/deliveries (slim list)
  - GET    /api/autopilots/{id}/deliveries/{deliveryId} (with raw_body)
  - POST   /api/autopilots/{id}/deliveries/{deliveryId}/replay -> creates
    a new delivery row (replayed_from_delivery_id set), dispatches a
    new run, never collapses onto the original via dedupe.
  - PUT    /api/autopilots/{id}/triggers/{triggerId}/signing-secret
    Write-only; trigger response surfaces has_signing_secret +
    signing_secret_hint (last 4 chars), never the secret itself.

Signature verification reuses the GitHub-compatible
X-Hub-Signature-256: sha256=<hex(hmac(body, secret))> scheme; the
HMAC helper is constant-time. Invalid/missing signatures still count
against per-IP and per-token rate limits.

autopilot_run.trigger_payload is intentionally preserved — delivery
records the HTTP receipt; run records the normalized envelope handed
to the agent. They are two different views.

Tests (Postgres-backed):
  - delivery persistence on accept
  - dedupe via Idempotency-Key and X-GitHub-Delivery; run_only retry
    storm pin (3 retries -> 1 run)
  - invalid signature: 401 + rejected row + no run linkage
  - missing signature when secret configured: 401 + 'missing' state
  - valid signature dispatches
  - signing secret never echoed in trigger responses; hint shows last 4
  - min-length and clear-by-empty for signing secret PUT
  - replay creates a NEW delivery + new run; rejected deliveries cannot
    be replayed
  - list omits raw_body; detail includes it; cross-autopilot ID returns
    404 (workspace isolation defense in depth)
  - provider validation: unknown -> 400, github -> 201 round-trips
  - bad-signature stream still counts against per-token rate limit

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilots): address PR review on webhook delivery layer (MUL-2334)

- Exclude `failed` from the (trigger_id, dedupe_key) partial unique index
  alongside `rejected`, so a transient ingress failure does not strand the
  provider's stable X-GitHub-Delivery / Idempotency-Key retry. Update the
  dedupe lookup to prefer non-terminal rows under the same predicate.
- Tighten delivery status enum: drop `skipped` from the CHECK constraint
  and from the handler. A run that was admission-skipped (e.g. runtime
  offline) is now recorded as delivery=`dispatched` linked to the
  skipped run, with the response payload carrying status=`skipped`.
  Source of truth for skipped-ness is autopilot_run.status, not the
  delivery row — keeps the Deliveries UI enum unambiguous.
- On dispatch error, link the (possibly non-nil) autopilot_run returned
  by DispatchAutopilot to the failed delivery so Deliveries UI can
  navigate to the run row for debugging.
- Slim list projection: ListWebhookDeliveriesByAutopilot no longer pulls
  raw_body / selected_headers / response_body — a 100-row page × 256 KiB
  would otherwise round-trip ~25 MiB from Postgres per Deliveries reload.
  Detail endpoint continues to return the full row.
- Fix backend CI: TestGetDelivery_ReturnsFullPayload now decodes the
  response and asserts on the parsed raw_body instead of substring-
  matching against an escaped JSON string; raise the test-suite default
  webhook rate limits in TestMain so the shared 192.0.2.1 IP bucket
  doesn't fill across the suite and leak 429s into unrelated tests.
- Add regression coverage for the dedupe-after-failure path.

cd server && go test ./... is green locally.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 14:59:40 +08:00
Naiyuan Qing
20c2f45b4a fix(views): surface backend error messages on mutation failures (MUL-2317) (#2772)
* fix(views): surface backend error messages on mutation failures (MUL-2317)

Mutation toasts across the views package were swallowing the backend
`error` string and showing only a generic i18n fallback. This made it
impossible for users to see why an operation failed (most visibly:
creating an issue with a duplicate title produced a vague "Failed to
create issue" toast).

The fix has three pieces:

1. Create-issue duplicate branch (A段)
   - New schema `DuplicateIssueErrorBodySchema` in core/api/schemas.ts.
   - `create-issue.tsx` parses `ApiError.body` via `parseWithFallback`
     and renders a dedicated amber-toned toast with a "view existing"
     link when the server returns `{ code: "active_duplicate_issue",
     issue: {...} }`. Schema drift downgrades to the normal error toast.
   - Schema intentionally omits `issue.status` so the toast does not
     depend on `StatusIcon`, which has no fallback for unknown enums.

2. User-facing mutation failure toasts (B段)
   - 47 sites converted to `err instanceof Error && err.message ?
     err.message : <existing fallback>` — preserves all existing
     code-specific branches (slug conflict, agent_unavailable,
     daemon_version_unsupported) and i18n keys.
   - Covers Type 1 (onError) and Type 2 (catch block) patterns across
     issues, projects, autopilots, inbox, runtimes, squads, comments,
     batch actions, workspace create, and agent config tabs.

3. Autopilot partial-success (Type 3)
   - New i18n keys `toast_create_partial_with_reason` /
     `toast_update_partial_with_reason` (double-brace `{{reason}}`).
   - `autopilot-dialog.tsx` captures `err.message` in the schedule
     `catch` and routes to the `_with_reason` variant when present,
     preserving the partial-success semantic (autopilot saved, schedule
     failed) while exposing the actual reason.

Explicitly out of scope:
- `packages/core/` mutation hooks (no global onError, no UI dependency)
- No `toastApiError` helper (matches existing 14+ correct sites)
- Sub-issue link aggregate `Promise.allSettled` keeps count-based toast
  (N independent requests cannot collapse to one err.message); only
  added a dev-side `console.error` per rejection.
- Clipboard catches and `useUpdateChatSession` (not API mutation toasts)

Tests:
- `packages/core/api/schemas.test.ts` — schema contract (valid body,
  forward-compat fields, rename rejection, missing issue, wrong types).
- `packages/views/modals/create-issue.test.tsx` — duplicate toast +
  view link, schema-drift fallback, err.message surfacing, non-Error
  fallback (4 new cases).
- `packages/views/autopilots/components/autopilot-dialog-i18n.test.ts`
  — real i18next, asserts rendered text contains the reason verbatim
  (guards against `{reason}` vs `{{reason}}` regression).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilots): unify rotate-token catch + cover dialog partial-success render

Address reviewer feedback on PR #2772:

1. webhook-token rotate (`autopilot-detail-page.tsx`) now follows the
   `err.message ?? fallback` ternary used by the sibling trigger
   delete/add paths, instead of swallowing the error.

2. Extract `formatSchedulePartialFailureToast` so the dialog's
   partial-success branches and the i18n test exercise the same
   helper. The test now drives the actual format function, so a
   variable-name typo at the call site (e.g. `{ msg }` instead of
   `{ reason }`) fails the substring assertion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(modals): drop user.type for title in success path to dodge CI 5s timeout

The success-path test typed the 42-character title via userEvent which
triggers a controlled re-render per keystroke. On the slower CI runner
the whole test crept up to ~5s and intermittently tripped the default
vitest timeout. Setting the value in one shot via fireEvent.change cuts
the cost while leaving the submit + toast interactions on userEvent.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 13:44:10 +08:00
Zohar Babin
15152c6ccd feat(auth): cache workspace membership for daemon heartbeat path (MUL-2247) (#2638)
* feat(auth): cache workspace membership for daemon heartbeat path

Cache workspace membership existence (not role) in Redis to eliminate a
DB round-trip on every PAT-authenticated daemon heartbeat. Follows the
existing PATCache nil-safe pattern.

Key design decisions per reviewer feedback:
- Cache existence only (sentinel "1"), not role string. Authorization
  decisions that depend on role always hit the DB directly. This
  eliminates the cache-aside race where a stale elevated role could
  persist after a downgrade.
- Proactive invalidation on UpdateMember, DeleteMember, LeaveWorkspace,
  and DeleteWorkspace (iterates members before cascade delete).
- 5 min TTL. Combined with PATCache (10 min), worst-case revocation
  delay is max(10m, 5m) = 10 min — consistent with original PATCache
  design decision.

Limitations:
- Non-members still hit DB on every request (negative caching not
  implemented — the scenario is rare for daemon endpoints which require
  valid workspace-scoped tokens).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(auth): drive membership cache invalidation through real handlers

- TestRequireDaemonWorkspaceAccess_CacheHit now uses a ghost user with no
  member row, so the only path to a granted access is the cache short-circuit.
  Without priming the cache the access check must fail; with priming it must
  succeed. A future change that bypasses the cache would fail the second
  assertion.
- Replaces the cache-only InvalidatedOnMemberRemoval test (which only
  re-exercised the auth-package primitive) with four handler-driven tests
  that exercise DeleteMember, UpdateMember, LeaveWorkspace and
  DeleteWorkspace via their real HTTP handlers. Each test prepares a real
  member, primes the cache, calls the handler, and asserts the cache entry
  is gone — so a refactor that drops one of the Invalidate(...) calls in
  workspace.go will fail CI.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-05-18 13:30:35 +08:00
Bohan Jiang
eb5c6d7547 docs(self-host): document auth rate-limit env keys (#2773)
Adds REDIS_URL, RATE_LIMIT_AUTH, RATE_LIMIT_AUTH_VERIFY, and
RATE_LIMIT_TRUSTED_PROXIES to the environment-variables page (EN +
ZH) and to .env.example, with the reverse-proxy caveat that without
RATE_LIMIT_TRUSTED_PROXIES every user shares the proxy IP and the
whole deployment ends up in one bucket.

Follow-up to #2636. MUL-2251.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 13:11:17 +08:00
Zohar Babin
e50bfc88da fix(auth): add per-IP rate limiting on public auth endpoints (#2636)
Adds a Redis-backed fixed-window rate limiter middleware on /auth/send-code,
/auth/verify-code, and /auth/google. Prevents brute-force enumeration,
verification_code table flooding, and connection pool exhaustion from
rapid-fire unauthenticated requests.

Key design decisions per reviewer feedback:

- X-Forwarded-For trust model: XFF is NEVER trusted by default. Only
  honored when RemoteAddr is from a CIDR in RATE_LIMIT_TRUSTED_PROXIES.
  Uses rightmost-untrusted algorithm (walks XFF right-to-left, returns
  first non-trusted IP). Matches the project's conservative model in
  health_realtime.go.

- Atomic INCR+EXPIRE via Lua script: prevents a stuck key (permanent
  ban) if EXPIRE fails independently. Follows existing Lua script
  pattern in runtime_local_skills_redis_store.go.

- Fixed-window counter (not sliding-window): simple, adequate for auth
  rate limiting where precision at window boundaries is acceptable.

- Fail-open with startup warning: nil Redis disables rate limiting
  (same as PATCache), but logs a warning at startup so ops can see.

- IPv6 normalization: net.ParseIP().String() produces canonical form.

- Configurable via env vars: RATE_LIMIT_AUTH (default 5/min),
  RATE_LIMIT_AUTH_VERIFY (default 20/min), RATE_LIMIT_TRUSTED_PROXIES.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 12:59:28 +08:00
Multica Eve
e8fb0efe3d MUL-2324 conditionally inject non-core rule blocks (#2771)
* feat(runtime): conditionally inject non-core rule blocks

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): tighten mention rule triggers

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 12:52:54 +08:00
Naiyuan Qing
d42fbcb794 fix(editor): sync ContentEditor when defaultValue changes externally (#2765)
* fix(editor): sync ContentEditor when defaultValue changes externally

Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831
— by design), so when an issue description is updated remotely (WS event,
another agent, another client), the editor kept showing stale content
until the issue was closed and reopened. `key={id}` in issue-detail only
force-remounts on issue switch, not on same-issue updates.

Add a useEffect in ContentEditor that watches `defaultValue` and applies
it via `editor.commands.setContent()` with four guards:

  1. Focused AND dirty — protect bytes the user is actively typing.
     Focused-but-clean intentionally falls through: onBlur has no replay
     path, so an unconditional `if (isFocused) return` would drop the
     sync forever for users who click into the editor without typing.
  2. Unfocused AND dirty — covers the blur → debounce (1500ms) window
     where the editor holds unsaved content but isFocused is already
     false. The pending onUpdate flush reconciles via the cache;
     overwriting here would be silent data loss.
  3. Normalized-equal short-circuit — avoids a no-op transaction when
     the cache reflects a write this editor just emitted.
  4. `emitUpdate: false` — Tiptap v3 flipped setContent's emitUpdate
     default to true; without this the sync would re-trigger onUpdate
     → server save → self-write loop.

After setContent, clamp the prior selection to the new doc size so the
caret doesn't snap to position 0.

Tests cover five cases: unfocused+dirty-content (sync fires),
focused+dirty (skip), focused+clean (must sync — regression guard for
the focused-but-clean hole), unfocused+dirty (blur-before-debounce
window, skip), and normalized-equal short-circuit (skip).

Closes #2409

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(editor): cover normalized-equal sync path with a distinct defaultValue

The previous rerender passed the same `defaultValue` string, so React's
dep-array equality short-circuited the sync effect entirely — the test
only exercised the first-mount equality check, not the actual
normalized-equal guard.

Pass a different-but-trimEnd-equivalent value so the effect re-runs and
the normalized-equal short-circuit is what keeps setContent uncalled.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 12:39:14 +08:00
johnhu-1237
79dd066363 fix env example websocket origin (#2599) 2026-05-18 12:38:52 +08:00
Multica Eve
58a76f6d96 fix(execenv): trim default runtime brief command list (MUL-2322) (#2769)
Trim the default runtime brief Available Commands to the agreed core set, including issue create/update, while keeping non-core commands discoverable through help. CI passed for backend and frontend.
2026-05-18 12:25:37 +08:00
Kerim Incedayi
9418d2a2c1 feat(autopilots): webhook triggers (server + CLI + UI + docs) MUL-2049 (#2348)
* feat(server): add webhook trigger DB migration + sqlc queries

Lays the foundation for webhook autopilot triggers:
- partial unique index on autopilot_trigger.webhook_token (kind=webhook only)
  so the public ingress route can resolve a trigger in O(1)
- GetWebhookTriggerByToken / TouchAutopilotTriggerFiredAt /
  RotateAutopilotTriggerWebhookToken / SetAutopilotTriggerWebhookToken
  queries, regenerated with sqlc

* feat(server): webhook token generator + payload normalizer

Two pure helpers for the webhook autopilot work:
- generateWebhookToken: 32 random bytes -> base64-url, "awt_" prefix.
  256 bits of entropy keeps brute-force off the table; the prefix makes
  leaked tokens recognisable in logs.
- normalizeWebhookPayload: turns arbitrary JSON into the WebhookEnvelope
  shape (event/eventPayload/request) used by trigger_payload. Header- and
  body-based event inference covers GitHub, GitLab, X-Event-Type, and
  caller-provided envelopes; scalar/empty/invalid bodies are rejected so
  the handler can answer 400.

* feat(server): generate webhook tokens and expose rotate endpoint

- New handler.Config.PublicURL fed by MULTICA_PUBLIC_URL env so
  /api/autopilots/.../triggers responses can include an absolute
  webhook_url alongside the always-present webhook_path.
- CreateAutopilotTrigger now mints a webhook_token via crypto/rand
  for kind=webhook and ignores cron/timezone for non-schedule kinds.
  api triggers stay accepted-but-inert per PLAN.md.
- New POST /api/autopilots/{id}/triggers/{triggerId}/rotate-webhook-token
  protected by the existing workspace auth group; old tokens stop
  working immediately because the unique-index lookup keys on the
  current row value.

* feat(server): public webhook ingress route + per-token rate limiter

- New POST /api/webhooks/autopilots/{token} route, mounted outside the
  authenticated group: the path token is the credential. Workspace
  context is derived from the joined autopilot row, never headers.
- Body capped at 256 KiB via http.MaxBytesReader; oversized payloads
  return 413 mid-read instead of being fully buffered.
- Disabled triggers / paused / archived autopilots return
  200 {"status":"ignored"} so providers stop retrying.
- Skipped-runtime dispatches surface 200 {"status":"skipped"} with the
  reason from the autopilot service's pre-flight admission check.
- WebhookRateLimiter interface with sliding-window in-memory + Redis
  Lua-script implementations. Default 60 req/min per token. Test
  coverage on the in-memory path; Redis variant fails open on cache
  errors so a Redis hiccup never blocks ingress.
- Integration tests exercise token generation, dispatch, payload
  envelope persistence, GitHub-header inference, paused/disabled
  short-circuits, oversized rejection, and rotate-then-old-token-404.

* feat(server): include webhook payload in create_issue description

When an autopilot run is triggered by a webhook and execution_mode is
create_issue, the agent only sees the issue body — never the run's
trigger_payload. Append a 'Webhook event:' line and a fenced JSON block
with the normalized eventPayload so the agent has the inbound context
inline. Schedule / manual runs are unchanged.

Tests cover:
  - schedule path keeps existing italic note, no webhook block
  - webhook path emits event line + payload block, italic before block
  - non-envelope JSON falls back to raw body (defensive)
  - non-webhook source with payload still gets no webhook block

* feat(core): types, API client and mutations for webhook triggers

- AutopilotRunStatus gains 'skipped' so the run-list UI handles the
  admission-skipped state explicitly instead of falling through to a
  generic case (the backend already emits it via MUL-1899).
- AutopilotTrigger picks up optional webhook_path / webhook_url. Both
  are optional so older self-hosted servers that pre-date this change
  still parse cleanly.
- buildAutopilotWebhookUrl helper composes a usable absolute URL with
  the priority webhook_url > apiBaseUrl + path > origin + path > path.
  Tested with seven cases covering each branch.
- ApiClient.rotateAutopilotTriggerWebhookToken posts to
  /api/autopilots/{id}/triggers/{triggerId}/rotate-webhook-token; the
  HTTP-contract test pins URL + method.
- useRotateAutopilotTriggerWebhookToken mutation invalidates
  autopilotKeys.detail on settle, mirroring the existing trigger-mutation
  pattern.

* feat(views): webhook trigger UI in Add Trigger dialog and trigger row

Add Trigger dialog gains a Schedule/Webhook segmented toggle:
  - Schedule reuses TriggerConfigSection unchanged.
  - Webhook hides the cron config and shows a help line; the trigger is
    created with kind=webhook and the URL is generated server-side.
  - Toast text differentiates schedule vs webhook on success.

TriggerRow grows a webhook branch:
  - Webhook icon, kind translated via trigger_kind.
  - URL shown in a truncating monospace pill, with copy + rotate
    buttons. Copy uses navigator.clipboard with toast feedback; rotate
    uses an AlertDialog confirm because the old URL stops working
    immediately.
  - api triggers render a Deprecated badge and skip URL/copy/rotate
    affordances.

RunRow gains a 'skipped' RUN_VISUAL entry (muted dash) so admission-
skipped runs don't fall through to a generic case. Source label uses the
new run_source i18n key instead of capitalize.

Locales: en + zh-Hans gain run_status.skipped, run_source.*,
trigger_kind.*, trigger_row.{copy_url,rotate_url,*_confirm_*,toast_*},
add_trigger_dialog.{type_*,webhook_help,toast_added_{schedule,webhook}}.

* feat(cli): support webhook trigger creation and URL rotation

- multica autopilot trigger-add now takes --kind schedule|webhook
  (default schedule for backward compatibility). For webhook it skips
  --cron / --timezone validation and prints the resulting webhook URL,
  preferring the server-provided webhook_url and falling back to
  client.BaseURL + webhook_path.
- New multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>
  command for rotating the bearer URL of a webhook trigger.

* docs(autopilots): add webhook trigger guide (en + zh)

Replaces the 'Webhook and API triggers are not available yet' section
with end-to-end webhook documentation: how the URL is generated, what
payload shapes are accepted, the inferred-event rules, the bearer-secret
warning + rotate flow, status-code semantics for accepted/skipped/
ignored/4xx/5xx outcomes, and the MULTICA_PUBLIC_URL self-host
configuration.

Run history list now mentions skipped status. The 'unavailable
features' section narrows to api-kind triggers, HMAC signing, IP
allowlists, and provider presets.

* feat(views): add Schedule/Webhook toggle to the create autopilot dialog

Closes the gap where a brand-new autopilot could only be created with a
schedule trigger. The right-column config now has a Trigger section
with a segmented Schedule/Webhook control:
  - Schedule keeps the existing cron/timezone UI.
  - Webhook hides the cron UI and shows a help line; on submit, a
    kind=webhook trigger is created right after the autopilot.

In edit mode the toggle is intentionally hidden (PLAN.md treats trigger-
type changes as delete-old + create-new, not in-place updates), but the
panel still picks the right kind based on props.triggers[0].kind so a
webhook autopilot doesn't render an irrelevant cron form.

Locales: section_trigger_kind, trigger_kind_{schedule,webhook},
section_webhook, webhook_help_{create,edit} added in en + zh-Hans.

* feat(views): show webhook URL inline after creating a webhook autopilot

After a successful create with kind=webhook, the dialog stays open and
swaps to a confirmation panel showing the freshly minted URL with a
copy button + 'Treat this URL like a password' warning + Done button.
Avoids the friction of "create the autopilot, then go find it in the
list, click in, scroll to triggers, copy URL."

Locales: dialog.webhook_created_{title,description,warning,done} added
in en + zh-Hans.

Schedule create flow is unchanged (toast + close). The success panel is
gated on the trigger returned from the create mutation, so a partial
failure (autopilot created, trigger creation errored) still falls
through to the toast_create_partial path.

* feat(views): show webhook payload in run detail dialog

The agent transcript dialog now accepts an optional headerSlot that
sits above the event list. The autopilot RunRow drops a
WebhookPayloadPreview into that slot when the run came from a webhook
and trigger_payload is non-empty.

The preview is collapsed by default (the transcript itself is the main
event), shows the inferred event name + receivedAt in the header, and
reveals the eventPayload as pretty-printed JSON with a copy button on
expand. Falls back gracefully if the row's trigger_payload doesn't
match the WebhookEnvelope shape — the whole value is shown instead so
nothing is hidden.

Closes the "agent didn't echo the payload, now I can't see what
triggered the run" gap. PLAN.md tracked this as
"Payload preview in run history" under follow-ups.

Locales: webhook_payload.{label, unknown_event, payload, content_type,
copy, copied, copied_short, copy_failed} added in en + zh-Hans.

* chore(server): wire MULTICA_PUBLIC_URL through self-host compose

Two small follow-ups split out of the webhook trigger PR:

- docker-compose.selfhost.yml passes MULTICA_PUBLIC_URL into the
  backend container so a self-hosted deployment behind a real domain
  gets absolute webhook URLs in the trigger response. Documented in
  .env.example with the rationale for not deriving the public host
  from request headers.
- Drop a duplicated 'invalid json:' prefix in the webhook ingress
  400 error path. normalizeWebhookPayload already prefixes its
  errors, so the handler doesn't need to re-prefix.

* fix(migrations): renumber webhook trigger migration 081 → 089 to avoid collision

The branch's 081_autopilot_webhook_triggers.{up,down}.sql collided
numerically with 081_runtime_timezone.{up,down}.sql that landed on
main, making migration apply order undefined. Renumber to 089 so the
file slots after the latest main migration (088_squad_instructions).

The SQL itself doesn't conflict — it only creates a partial unique
index on autopilot_trigger.webhook_token — but the duplicate prefix
is what the migration runner sees, so the filename must move.

* fix(autopilot-webhook): address PR review blocking issues

- Redact bearer tokens from request logs: paths matching
  /api/webhooks/autopilots/<token> now log "[redacted]" instead of the
  token. The resolved trigger ID is plumbed via context so audit lines
  stay useful for debugging. (Review item Blocking #1.)
- Distinguish pgx.ErrNoRows from transient DB errors in token lookup:
  no-row stays 404 (so providers don't retry on a deleted webhook),
  other errors return 500 (which providers DO retry, avoiding silent
  drops on DB blips). (Review item Blocking #2.)
- Add per-IP sliding-window rate limiter that runs BEFORE the token
  lookup, so spraying random tokens can no longer probe the
  autopilot_trigger index unboundedly. Reuses the existing Lua script
  with a separate Redis key namespace; falls open on Redis errors.
  Default budget 30 req/min/IP. (Review item Blocking #3.)

The webhook handler now applies the gates in the order: per-IP rate
limit → token lookup → per-token rate limit → handler logic.

* fix(autopilot): atomic webhook trigger creation + strict kind/timezone validation

- Mint the webhook bearer token BEFORE the INSERT and pass it via
  CreateAutopilotTriggerParams so the row never exists in a half-written
  kind=webhook + webhook_token=NULL state. On the (vanishingly rare)
  unique-index collision the whole INSERT is retried with a fresh token
  — no UPDATE second step. Removes the now-dead attachFreshWebhookToken
  helper. (Review item Recommended #4.)
- Add new GET /api/autopilots/{id}/runs/{runId} endpoint that returns a
  single run including the full trigger_payload. The list response is
  now slim (omits trigger_payload) so worst-case payload size drops
  from ~5 MB to ~5 KB. (Review item Recommended #5, server side.)
- Reject kind=api with 400 ("kind=api is deprecated; use schedule or
  webhook") and reject kind=webhook with --timezone with 400 — both
  surfaces stragglers loudly instead of silently dropping fields.
  CLI mirrors the check so --timezone with --kind webhook errors
  client-side. (Review nits.)
- Add --yes (-y) flag and an interactive y/N confirmation prompt to
  `multica autopilot trigger-rotate-url` so the destructive rotate
  matches the UI's AlertDialog safety. (Review item Recommended #6.)

* fix(views): fetch webhook payload on-demand and truncate at 4 KiB

- Add useAutopilotRun query hook + getAutopilotRun API client method
  paired with the new server endpoint. The run-detail dialog now mounts
  a WebhookPayloadSlot that fetches the full run (incl. trigger_payload)
  lazily — list responses no longer carry up to 256 KiB × N runs of
  envelope data.
- WebhookPayloadPreview truncates its in-DOM <pre> at 4 KiB with a
  localized marker so jank-y machines aren't asked to render a 256 KiB
  JSON blob. The Copy button still yields the full string.
- Adds the truncated_marker i18n string to en + zh-Hans.

Review items Recommended #5 (frontend) and a nit on the preview's
unbounded <pre>.

* test(autopilot-webhook): close coverage gaps flagged in PR review

- request_logger: redactWebhookPath unit tests + integration test
  proving the bearer token never lands in slog output, plus the
  webhook_trigger_id context plumbing.
- autopilot_webhook_handler: empty body → 400, archived autopilot →
  200 ignored, per-IP rate limiter trips before DB lookup, kind=api
  and webhook+timezone are rejected at 400, slim list + full detail
  endpoint round-trip.
- webhook_rate_limiter: Lua script structure guard (catches reordering
  even without a live Redis), plus live-Redis tests for both per-token
  and per-IP limiters (REDIS_TEST_URL gated, matching the existing
  Redis test pattern in the package).
- WebhookPayloadPreview: envelope rendering, fallback shape, and the
  >4 KiB truncation path with full-payload-on-Copy guarantee.

Two branches are documented as code-review-protected rather than
covered by tests: the 500-on-DB-error path requires injecting a stub
Queries (no interface here), and the cross-workspace defense-in-depth
check is unreachable from valid SQL state.

* fix(middleware): SetWebhookTriggerID must mutate request in place

The round-1 helper returned a fresh *http.Request from WithContext, and
the webhook handler did `r = SetWebhookTriggerID(r, ...)`. That swaps
the handler's local pointer but doesn't propagate the new context back
to RequestLogger, which is still holding the original *http.Request —
so the audit line never actually included webhook_trigger_id in
production. The round-1 test happened to pass because it pre-stashed
the value on the request before calling ServeHTTP, bypassing the bug
it was meant to verify.

Switch to in-place mutation via `*r = *r.WithContext(...)` so the
wrapping middleware sees the new context after next.ServeHTTP returns,
and update the test to exercise the real call pattern (set the context
from inside the handler, assert the surrounding logger reads it).

Verified live: an accepted webhook now logs
  path=/api/webhooks/autopilots/[redacted] webhook_trigger_id=<uuid>

* fix(autopilot-webhook): symmetric ErrNoRows split + trusted-proxy gate

Round-2 review (Bohan-J, PR #2348 follow-up):

- Must-fix #1: the second lookup at autopilot_webhook.go:258
  (GetAutopilot after the token resolves) was folding every error into
  404. A transient DB blip would tell a webhook sender "not found" and
  it would never retry. Apply the same errors.Is(err, pgx.ErrNoRows)
  → 404 / else → 500 split as the first lookup got in round 1.

- Must-fix #2: clientIPForRateLimit was honoring X-Forwarded-For /
  X-Real-IP from any caller. An attacker spraying random tokens could
  just rotate the XFF header and the per-IP bucket became per-request,
  so the limiter that's specifically supposed to gate spraying before
  it hits the DB unique index was bypassed.

  New shape — matches Bohan's suggestion exactly:
  * Default: r.RemoteAddr only, headers ignored.
  * Operator opt-in via MULTICA_TRUSTED_PROXIES (comma-separated
    CIDRs). XFF/X-Real-IP are honored only when r.RemoteAddr is
    inside one of the listed prefixes; otherwise they're dropped.

  Wired through .env.example and docker-compose.selfhost.yml so
  self-host operators can configure their reverse-proxy's CIDR.
  Invalid CIDRs in the env var are dropped with a single slog.Warn at
  startup rather than crashing the server. Uses net/netip (stdlib,
  value-typed) for parsing and containment checks.

Verified live on the rebuilt self-host backend: a 35-request spray
from one source with rotating XFF gets the expected 30× 404 + 5× 429,
proving the per-IP bucket is keyed on the real connection IP.

* fix(autopilot): reject cron/timezone PATCH on non-schedule triggers

Round-2 review should-fix. CreateAutopilotTrigger already 400s on
kind=webhook + timezone/cron_expression, but UpdateAutopilotTrigger
silently wrote those fields regardless of prev.Kind. The values then
sat in the DB visible to nobody and read by nothing — a back door that
left the API contract fuzzy across create vs update.

Mirror the create-path discipline: after loading prev, if prev.Kind
!= "schedule" and the PATCH body sets cron_expression or timezone,
return 400 with a clear message. enabled and label remain accepted on
every kind.

The existing prev.Kind == "schedule" guard on next_run_at recompute
stays as belt-and-braces, but with this gate in place the recompute
branch is now reachable only for the kind it was meant for.

* test(autopilot-webhook): close round-2 coverage gaps

- IPRateLimitNotBypassedByXFFSpoof: drives the must-fix #2 invariant
  by rotating XFF across three calls from the same RemoteAddr and
  asserting the third gets 429. Pre-round-2 this test would have
  passed for the wrong reason (limiter trusted XFF, so per-bucket
  collision was incidental); now it pins the bypass-closed property.
- IPRateLimitReturns429BeforeDBLookup: updated to set RemoteAddr
  explicitly and drop the XFF header it was leaning on. With
  TrustedProxies empty (test default) the limiter keys on the real
  connection IP, which is what the test wants to assert anyway.
- UpdateAutopilotTrigger_RejectsCronExpressionOnWebhookKind +
  UpdateAutopilotTrigger_RejectsTimezoneOnWebhookKind: drive the
  round-2 should-fix from the handler boundary.
- UpdateAutopilotTrigger_AcceptsEnabledAndLabelOnWebhookKind: counter
  test so a regression to a blanket reject is caught.

* fix(migrations): bump webhook trigger migration 089 → 091

origin/main added 089_squad_no_action_activity_index (and 090_task_is_leader)
since our last rebase, re-colliding with our 089_autopilot_webhook_triggers.
Bump to 091 so the filename ordering is unambiguous again. The SQL is
unchanged — same partial unique index on autopilot_trigger.webhook_token —
only the filename moves.

* fix(views): dedupe skipped icon in autopilot RUN_VISUAL after rebase

The rebase against origin/main merged main's add of `Ban` for the
skipped status next to our round-1 `MinusCircle` entry, leaving the
RUN_VISUAL map with two `skipped` keys (only the last would have been
read at runtime, and MinusCircle had been dropped from the imports
during conflict resolution — so the file would not compile).

Keep main's `Ban` icon (latest design) and a single `skipped` entry.
Carry over the round-1 comment about why the muted styling matters
for failure-ratio readability.

---------

Co-authored-by: Kerim Incedayi <kerim.incedayi@digitalchargingsolutions.com>
2026-05-18 12:17:39 +08:00
Jiayuan Zhang
7c3dab695f fix(runtimes): stop surfacing agent CLI version branding in machine subtitle (#2752)
compactDeviceInfo was flipping the parenthetical of an agent CLI version
string (e.g. "2.1.5 (Claude Code)" -> "Claude Code 2.1.5") and using that
as the per-machine subtitle. Each daemon's runtimes are sorted alphabetically
and `claude` always sorts first, so every claude-equipped machine's row
ended up showing "Claude Code …" — drowning out actual per-machine differences.

The reshape was meant for OS+arch shapes ("macOS (x86_64)" -> "x86_64 macOS"),
not version strings. Filter agent-version-like parts out before picking a
primary so the subtitle either reflects real machine info or falls back to
the daemon-id descriptor.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 11:06:19 +08:00
Jiayuan Zhang
f1c9617b5e feat(runtimes): Redesign runtimes machine layout (#2747) 2026-05-17 23:14:22 +08:00
Naiyuan Qing
e4ecb722fe docs(mobile): correct Debug-vs-Release standalone claim and env reload semantics
Two corrections to docs landed earlier this branch:

- The README told self-host users that ios:device:staging "runs without
  the Mac after the build completes." That is wrong for the Debug build
  it produces: every launch the embedded expo-dev-launcher probes Metro,
  showing a "Downloading…" / Recently-opened screen and stalling when the
  Mac is asleep or unreachable. Split the section into two paths and
  recommend the new :release variant for standalone use.

- The .env.example said changing a value "requires re-running an ios:*
  build" and that "dev:* (Metro) alone will not refresh baked-in values."
  That is only true for an installed Release build. For Debug, restarting
  Metro is sufficient — it re-reads .env on startup and inlines the new
  values into the next JS bundle it serves. Rewrite the comment to
  distinguish the two cases.

Also drop stale references to the removed ios:mobile:sim* scripts from
the env example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:27:54 +08:00
Naiyuan Qing
de235fd5fd feat(mobile): add ios:device:staging:release build script
Adds a Release-configuration build path for the staging variant:

  pnpm ios:mobile:device:staging:release
  → cd apps/mobile && expo run:ios --device --configuration Release

Release builds strip `expo-dev-launcher` from the binary (it's only
linked in the Debug Pod configuration), so the installed app loads the
embedded JS bundle directly — no "Downloading…" screen, no Metro
probe, no Recently-opened launcher menu. Standalone use feels like an
App Store install.

The existing `ios:device:staging` (Debug) path is unchanged — it stays
the daily-driver for hot-reload development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:27:31 +08:00
Naiyuan Qing
87cc3093ab fix(mobile): chat tab badge stuck or self-clearing in background
Two paired bugs in the auto-markRead effect:

1. A `lastMarkedRef` short-circuited every re-fire of the effect, so once
   a session was marked read, a subsequent chat:done arriving on the same
   session left the badge stuck at 1 forever.

2. With (1) gone, the effect re-fired even while the Chat tab was
   backgrounded (React Navigation keeps sibling tabs mounted), silently
   clearing unread state the user never had a chance to see.

Mirror web's chat-window.tsx logic: gate on `useIsFocused()` (mobile's
analogue of web's `isOpen`), and rely on has_unread itself as the dedup
signal — the mutation's optimistic patch flips it false immediately, so
the effect won't re-fire until the next chat:done flips it true again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:35:01 +08:00
Naiyuan Qing
e8c4c87741 docs(mobile): env example documents all six build/dev scripts
Previous template only mentioned the two dev:mobile* (Metro) scripts.
Now lists all six commands that read .env.development.local / .env.staging,
and flags the compile-time-baked gotcha: changing a value requires a
re-run of an ios:* build before an installed app sees the new value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:21:30 +08:00
Naiyuan Qing
3d0c87d84f fix(mobile): remove alpha channel from app icon
iOS app icons must not have an alpha channel — transparent backgrounds
can render as a blank/default icon on the device home screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:21:24 +08:00
Naiyuan Qing
48f912c3ea chore(mobile): add iOS app icon (shared 1024x1024 with desktop)
Adds apps/mobile/assets/icon.png (copy of apps/desktop/build/icon.png,
1024x1024 RGBA) and points the Expo config at it. Resolves the
\"No icon is defined in the Expo config\" warning on prebuild / EAS build.

Single-source: any brand refresh updates desktop's icon, then mirrors
into apps/mobile/assets/. Expo prebuild generates every required iOS
icon size from this one PNG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:01:17 +08:00
Naiyuan Qing
332ac868ce fix(mobile): escape apostrophes in login + select-workspace copy
CI lint failed on react/no-unescaped-entities. Two pre-existing JSX
literals contained raw apostrophes; replace with &apos;.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:54:44 +08:00
Naiyuan Qing
7bd13c83c0 docs: add iOS mobile client section + apps/mobile/README
Adds a pointer from the root README (EN + zh) to apps/mobile/, plus a
mobile-specific README covering scripts, env files, and the build-onto-
your-own-iPhone path for self-hosters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:49:14 +08:00
Naiyuan Qing
8811f72bc2 feat(mobile): squad @-mention support in issue composer
Adds squad rows to the @-mention suggestion bar — picker / serializer /
actor name lookup. Selecting a squad emits a `mention://squad/<uuid>`
token; backend wakes the squad's leader. Mirrors web's mention extension
(packages/views/editor/extensions/mention-suggestion.tsx): alphabetical
sort, archived hidden, distinct "Squad" badge.

Also adds a presence dot to the agent suggestion row in the same bar
(opt-in showPresence prop on ActorAvatar, mirroring 12 other call sites
on this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:49:05 +08:00
Naiyuan Qing
e746b1b3bd feat(mobile): ambient agent-working badge in issue header
Adds an always-visible "agent is working" indicator next to the issue
detail Stack header — a small AvatarStack + green PulseDot that opens the
Runs sheet on tap. Pairs with the existing in-card AgentActivityRow, which
is the first-time discovery surface; the header badge is the ambient
surface that stays put while the user scrolls the timeline (agent tasks
run minutes to tens of minutes).

Refactors AgentActivityRow + RunsSheet to dispatch through a shared
useRunsSheetStore (Zustand), since the Stack-header tree and the page-body
tree can't share local React state across that boundary on Expo Router.

Rationale: Apple HIG "Progress Indicators" + agent-UX ambient status
pattern. See plan /Users/qingnaiyuan/.claude/plans/ok-plan-linked-taco.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:48:45 +08:00
Naiyuan Qing
48282fa973 feat(mobile): agent presence dots + offline banner
Mirrors web's agent presence semantics (packages/core/agents/derive-presence.ts)
on iOS: 3-state availability (online / unstable / offline) derived from
runtime.status + last_seen_at + task snapshot, with a 30s wall-clock tick so
the 5-min unstable window decays without new server data.

Pure derivation imported from @multica/core/agents (whitelisted). React glue
(hook + WS + UI) is mobile-owned per the Sharing Principles in
apps/mobile/CLAUDE.md.

Wired into 12 avatar call sites via an opt-in showPresence prop:
chat-header / agent-picker / session-sheet / inbox-row / issue-row /
attribute-row / create-form-attribute-row / comment-card / run-row /
project lead + picker. Chat composer gets an OfflineBanner above it that
stays silent during loading.

Two mobile-specific tweaks vs web:
- 30s tick is AppState-gated and forces a recompute on foreground resume
  (iOS freezes JS timers in background).
- daemon:heartbeat / task:progress / task:message are explicitly skipped
  from the WS invalidation list — high-frequency events would burn cellular
  data; web already documented this footgun in use-realtime-sync.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:48:32 +08:00
Naiyuan Qing
7c00f88f68 Merge remote-tracking branch 'origin/main' into feat/mobile-ios
# Conflicts:
#	pnpm-lock.yaml
2026-05-15 16:03:29 +08:00
Naiyuan Qing
38313a62f7 feat(mobile): OTP code input + resend cooldown on verify screen
Replace the generic Input on the email-verify screen with a 6-slot
SF-styled OTP component (`input-otp-native`). Auto-submits on the
final keystroke instead of requiring a tap on the Verify button, and
exposes a `clear()` ref so the input resets after a server-side
rejection.

Add a 60-second resend cooldown with a live countdown beneath the
input, calling `auth.sendCode` on tap. Clears the previous code +
error when a new code is requested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:51 +08:00
Naiyuan Qing
db83cf9bfb refactor(mobile): always-on issue comment composer
Drop the tap-to-expand pill state machine. The composer now mounts in
its full form (input + @ / 📷 / 📎 / Send action row) immediately, with
no compact-pill intermediate state. Tap focuses the input and opens the
keyboard directly.

The pill→expand pattern was added to mirror chat composer's two-state
UX, but on a primary input surface like comments it is pure friction:
the user always has to tap once to get the affordance they came to use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:40 +08:00
Naiyuan Qing
0ea776e14e feat(mobile): swap tab bar icons to SF Symbols
Use expo-image's `sf:` source URLs for the four tab icons (tray /
checklist / bubble.left / ellipsis) instead of Ionicons. Native SF
Symbols render at the iOS standard tab-bar weight and stroke, so the
bar matches first-party iOS apps visually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:29 +08:00
Naiyuan Qing
5a196225b8 refactor(mobile): simplify TextField primitive
Strip the four cross-platform RN TextInput workaround comments down to
the two notes that still apply. Anchor height with `h-10` instead of
`paddingVertical: 0`, and inline `fontSize` to avoid NativeWind mapping
to fontSize+lineHeight (RN clips descenders when lineHeight is set on
iOS TextInput).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:21 +08:00
Naiyuan Qing
eeac71233e fix(mobile): close API schema + polymorphic-actor parity gaps
Three real bugs uncovered by the apps/mobile/ code review, all unprotected
by parseWithFallback or by the actor/assignee polymorphism:

- ActorAvatar + useActorLookup did not accept "system" actors. Inbox items
  with actor_type="system" (platform-triggered notifications) rendered a
  blank circle. Add a system glyph branch + widen the lookup signature.

- AssigneeValue was narrowed to "member" | "agent", silently dropping
  squad assignments coming from web/desktop and preventing the user from
  clearing them on mobile. Widen to IssueAssigneeType and render squad
  assignees with a generic group glyph (no squad list query yet — picker
  still lists members + agents only, but Unassigned now clears squads).

- Six read endpoints (getMe, listWorkspaces, listInbox, listMembers,
  listAgents, getIssue) returned bare fetch<T>() casts with no schema
  validation, violating the "API Response Compatibility" rule that
  installed-app architectures depend on. Add zod schemas with .loose()
  and enum-drift .catch() defenses, plus EMPTY_* sentinels so drift
  downgrades to "stale defaults render" instead of crashing the boot
  sequence.

Also fixes the AttachmentSchema typecheck failure by adding the missing
chat_session_id and chat_message_id fields (mobile schema had drifted
from packages/core/types/attachment.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:00:09 +08:00
Naiyuan Qing
794c3a1a98 feat(mobile): issue detail delete via three-dot header menu
Issue detail had no headerRight menu, leaving users unable to delete
issues from the phone. Adds the same ActionSheetIOS pattern the project
detail screen already uses: Copy link / Open on web / Delete (red,
Alert-confirmed). Property edits stay on IssueHeaderCard chips — one
entry per action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:23:03 +08:00
Naiyuan Qing
c1452c7330 feat(mobile): inbox swipe-to-archive + batch menu
Closes the inbox archive gap on mobile — desktop made archive a
first-class action (hover icon + batch dropdown) but mobile had no
archive entry point at all. Adds the canonical iOS pattern: left-swipe
on a row reveals a destructive Archive button, full swipe auto-fires.
Header gains a three-action menu for "archive all read / completed /
all" mirroring the desktop dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:22:45 +08:00
Naiyuan Qing
11913d18f4 feat(mobile): show agent runs on issue detail
New double-state row inside IssueHeaderCard (between title and
attributes): "[👤👤👤] Working" + pulse dot when ≥1 active task,
"Runs · N" when only past runs exist, hidden otherwise. Tap opens a
pageSheet listing Active + Past runs with status badges and an inline
Cancel button on active rows.

Data layer:
- api.ts: listActiveTasksForIssue (GET /api/issues/:id/active-task)
  and listTasksByIssue (GET /api/issues/:id/task-runs), both run
  through parseWithFallback + a new AgentTaskSchema (lenient enums
  with .catch() for forward-compat)
- queries/issue-keys.ts + queries/issues.ts: activeTasks + tasks
  options, workspace-scoped, signal forwarded
- mutations/issues.ts: useCancelTask with optimistic remove + rollback
- realtime/use-issue-realtime.ts: task:* WS events now invalidate the
  two new task queries (in addition to detail+timeline), so the row
  and sheet update without polling

New components: AgentActivityRow (the row), RunsSheet (built on
SheetShell), RunRow (single task row, cancel action), AvatarStack
(mobile-native overlapping avatars).

Transcript drilldown deferred to a follow-up — past row tap is no-op
in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:11:12 +08:00
Naiyuan Qing
f9cab33d94 refactor(mobile): standardize sheets on iOS pageSheet via SheetShell
The 16 Modal-based sheets in apps/mobile/ all copy-pasted the same
transparent-fade + hand-drawn backdrop + maxHeight pattern from the
project's first sheet. That shape is right for short action menus but
wrong for content viewing / search / forms — each subsequent sheet hit
its own bug (keyboard squash, FlatList clipping, useSafeAreaInsets
returning 0 inside Modal, "floating" feel from transparent backdrop).

Introduce SheetShell — a shared primitive wrapping Modal
presentationStyle="pageSheet" + nested SafeAreaProvider + header
(title + X) + safe-area-aware body. Migrate 7 misclassified sheets:
session, issue-filter, assignee/label/project/project-lead pickers,
add-resource. Codify the container-selection rule as CLAUDE.md Lesson
#6 so the next sheet doesn't inherit the wrong shape.

A-class sheets (comment-action, emoji-picker, fixed-option pickers)
intentionally left alone — their content matches the original pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:11:00 +08:00
Naiyuan Qing
6519e23282 feat(mobile): comment composer tap-to-expand two-state UX
CommentComposer's previous "stacked horizontal bars" layout (replying-
to chip + 7-button MarkdownToolbar + TextInput row + floating Send)
looked nothing like the chat composer beside it and dominated ~120pt
of vertical space on the issue detail screen even when no one was
composing.

Rewritten as a compact pill that taps open into a chat-composer-shaped
floating card. State machine is blur-driven:

- compact + tap pill → expanded, focus TextInput via useRef + rAF
  (autoFocus on conditional render is unreliable across iOS/Android)
- expanded + onBlur + text empty + no replyingTo → collapse to compact
- expanded + onBlur + has text or replyingTo → stay expanded; draft
  visible, user can scroll the timeline without losing context
- send success resets text but does not collapse — next blur drives it,
  so back-to-back sends don't make the card jump

In-card action row mirrors chat: @ · 📷 · 📎 left, Send right.
File / image upload reuses useFileAttach and inserts the existing
markdown formats (![](url), [📎 name](url)) — no backend changes.

Drops MarkdownToolbar entirely (list/checkbox/code/quote) — users can
still type those by hand and the timeline renderer is unchanged. The
replyingTo chip moves to a rounded pill above the card (border-b would
have clashed visually with the rounded-3xl card geometry).

Also fixes a pre-existing race: canSend now gates on !fileAttach.
uploading so a deferred insertAtCursor can't land in an already-cleared
input. Hardens canCancelReply: blur the input when reply is cleared
with empty text, so the existing collapse rule fires uniformly without
forcing manual keyboard dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:55:23 +08:00
Naiyuan Qing
af2369002c feat(mobile): TextField + AutosizeTextArea primitives
Mobile had 16 bare <TextInput> sites and a shared <Input> component
that nothing used. Every screen author repeated the four RN cross-
platform workarounds independently — paddingVertical:0, includeFont
Padding:false, textAlignVertical, and (for multiline) the onContentSize
Change + height-state dance — and most missed at least one.

This commit introduces two primitives that bake those in:

- <TextField> — single-line baseline with variant="filled" (default).
  Locks multiline={false} + numberOfLines={1} so callers can't mix
  iOS UITextField / UITextView modes by accident.

- <AutosizeTextArea> — multiline that actually grows with content,
  via onContentSizeChange → useState(height) clamp to [minHeight,
  maxHeight]. RN's Yoga doesn't read native intrinsicContentSize
  (facebook/react-native#54570, open), so this is the only way the
  bounding box keeps up with text. scrollEnabled flips on at the
  ceiling so a tall draft becomes internally scrollable instead of
  pushing the layout open.

Migrated 8 of 16 sites — chat composer, 3 description fields (new
issue, project new, project edit), and 4 picker sheets (label,
project, assignee, add-resource). Comment composer migration ships
in the follow-up commit since it's bundled with the redesign.

login / verify / search / hero titles + variant="outlined" / size="hero"
intentionally deferred (Out of Scope per plan) — no user-reported bug,
add them when the migration earns its weight.

<Input> is repurposed as a re-export of <TextField> so any future
import-by-name resolves to a sensible primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:55:03 +08:00
Naiyuan Qing
233fdd66bc feat(mobile): inbox → comment deep-link with flash highlight
When a user taps a new_comment / mentioned / reaction_added inbox row,
the issue detail screen now auto-scrolls to the target comment and
flashes it (matching web's behavior at packages/views/issues/components/
issue-detail.tsx:686-709). Replies are folded into their parent's
CommentCard, so a reply deep-link scrolls to the parent row and lights
up the matching child View only — mirroring web's replyToRoot fallback.

- Inbox tap now uses object-form router.push with highlight + h (nonce)
  params so re-tapping the same row re-fires the effect.
- TimelineList owns scrollToIndex (data-relative, viewPosition 0.3) with
  the standard onScrollToIndexFailed estimate-then-retry dance for
  variable-height rows.
- CommentCard renders an absolute-positioned Reanimated overlay
  (borderWidth + bg wash for root, bg-only for reply) driven by a single
  sharedValue with withSequence(700ms in, 1800ms hold, 700ms out) —
  matching web's transition-colors duration-700 + setTimeout(2500) timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:54:40 +08:00
Naiyuan Qing
542e6b461c fix(mobile): stop emoji clipping in ProjectIcon
Previous impl rendered the emoji as <Text leading-none>. On iOS, emoji
glyphs render ~10-15% larger than fontSize because they ignore latin
baseline metrics, and <Text> clips content to lineHeight — so the top
and bottom of every project emoji were being cut off. project-row.tsx
had a pt-0.5 compensation that only nudged the top, leaving the bottom
clipped and producing the "row height feels off" visual.

Wrap the Text in a fixed square View (sm=18 / md=22 / lg=28 px), set
explicit lineHeight = round(fontSize * 1.2) so the glyph has the room
it needs. Drop the pt-0.5 hack — the icon now self-centers cleanly and
flex parents using items-start / items-center align siblings against a
stable square footprint.

Affects every ProjectIcon call site: search rows, Projects list,
project header card, issue attribute / create-form rows, project
picker sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:27:23 +08:00
Naiyuan Qing
7aad870f06 feat(mobile): workspace search modal
Wires the header search icon to a working modal — debounced search
across issues + projects, Recent as empty state, modal-to-detail via
router.replace. Behavioral parity with packages/views/search but stays
search-only (no command-palette section) so it doesn't dual-list
targets already in the More popover.

- data/schemas.ts: SearchIssuesResponseSchema / SearchProjectsResponseSchema
  with enum-drift defense (match_source falls back to "title")
- data/api.ts: searchIssues / searchProjects with AbortSignal forwarding
  and parseWithFallback
- (app)/[workspace]/search.tsx: TextInput + 300ms debounce + abort,
  single FlatList driving Recent / Projects / Issues rows, snippet
  line for comment-matches mirrors web search-command.tsx:632

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:27:13 +08:00
Naiyuan Qing
2a3accb5d7 feat(mobile): unread badges on Inbox and Chat tabs
Surface the same unread signals web puts on the sidebar (inbox) and
the ChatFab (chat). On a phone the user lives on the tab bar, so
mounting badges directly on the Inbox and Chat tabs is the closest
equivalent.

Display semantics mirror web exactly (apps/mobile/CLAUDE.md "counts
must agree"):

- Inbox badge = `deduplicateInboxItems(items).filter(i => !i.read).length`,
  same as web's `useInboxUnreadCount` (packages/core/inbox/queries.ts:22).
  99+ truncation matches the sidebar.
- Chat badge = `sessions.filter(s => s.has_unread).length`, same as web's
  ChatFab (packages/views/chat/components/chat-fab.tsx:29). 9+ truncation
  matches the fab.

Implementation:
- New `apps/mobile/lib/unread-counts.ts` with two `useQuery + select`
  hooks; mirror-don't-import the web design.
- Wired into `(tabs)/_layout.tsx` as React Navigation's native
  `tabBarBadge` + `tabBarBadgeStyle`. Style is JUST `backgroundColor`
  (brand blue `#4571e0`); @react-navigation/elements `Badge` internally
  uses `borderRadius = size / 2` and `minWidth = size`, so the
  single-character badge renders as a true circle. Overriding minWidth /
  fontSize / fontWeight breaks that geometry — keep the override minimal.
- Brand blue chosen over the iOS default red: matches web's
  ChatFab `bg-brand` pip and avoids the "error / critical" connotation
  red carries for an everyday new-comment notification.

Both queries (`inboxListOptions`, `chatSessionsOptions`) are already
kept fresh by listing-level realtime hooks mounted in
`app/(app)/[workspace]/_layout.tsx` (`useInboxRealtime` /
`useChatSessionsRealtime`), so badges update via WS events without a
poll or focus refetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:54:21 +08:00
Naiyuan Qing
235c9e82e2 fix(mobile): inbox row flips to read state before navigation push
Tapping an unread inbox row produced no visible "now read" feedback —
the row disappeared into the issue detail push transition still wearing
its unread bullet and bold-foreground style. Users came back via the
back button to find it had become read (correct cache state, just no
real-time feedback).

Root cause: `useMarkInboxRead.onMutate` does `await qc.cancelQueries`
before the optimistic `setQueryData`, so the optimistic write lands one
microtask after the synchronous `router.push`. iOS native stack
captures the source view screenshot at push time — the screenshot freezes
the row in its unread state, and the transition animates that frozen
frame regardless of any later cache write.

Fix: in `onPressItem`, do the optimistic `setQueryData` synchronously
right before calling `markRead.mutate(...)`. The mutation still runs
end-to-end (so the server PATCH fires and `onSettled` invalidate
reconciles), but the row already shows the read style on the frame
that gets screenshotted for the push transition. The tab-bar inbox
badge also drops one count at the same instant for the same reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:53:44 +08:00
Naiyuan Qing
f7e56577ab feat(mobile): default Chat tab to most recent session on first entry
Web's chat-window opens to an empty state when no activeSessionId is
persisted, because the sidebar SessionDropdown makes one-click switching
cheap. On a phone, picking a session is 4 taps (header → sheet open →
row → close), so an always-empty default is friction — users complained
they had to re-pick the session every cold start.

Mobile-only deviation: on the first Chat tab entry for a given
workspace, jump straight to the most recent session (`sessions[0]`,
server-sorted by `updated_at desc`). A per-workspace `useRef` flag
makes the hydration a one-shot — subsequent user intent (point + New,
delete-active) sets activeSessionId to null and is respected forever
after. When the user switches workspaces, the ref resets so the new
workspace gets its own first-entry hydration.

Behavioural parity is preserved: counts / visibility / permissions /
enums match web exactly. UX is allowed to diverge on UI mechanics per
apps/mobile/CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:53:20 +08:00
Naiyuan Qing
caa9c4946e fix(mobile): restore Chat list scrolling + align bubble UI with web
The Chat tab message list was unscrollable. Two distinct root causes
under the same surface symptom:

1. Wrapper hijacking the touch responder. chat.tsx mounted a
   Pressable around ChatMessageList to implement "tap empty area =
   dismiss keyboard". Any Touchable* (Pressable / TouchableWithoutFeedback /
   TouchableOpacity) claims the responder via the shared Touchable mixin
   and does NOT reliably hand it back to the child FlatList for pan
   gestures, killing scroll. Removed entirely — `keyboardShouldPersistTaps
   ="handled"` on the FlatList already provides the same behaviour per
   RN docs (a tap not handled by a child bubble dismisses the keyboard),
   and `keyboardDismissMode="interactive"` covers drag-to-dismiss. Mirrors
   web's bare `<div className="flex-1 overflow-y-auto">` mount.

2. `onContentSizeChange` re-sticking to bottom on every async layout.
   Markdown async rendering (Shiki highlight, image natural-size
   resolution, lightbox provider injection) fires content-size changes
   for seconds after first paint. The previous handler called
   `scrollToEnd` unconditionally, snapping the user back to the bottom
   the instant they tried to drag up. Replaced with a sticky-bottom
   state machine — `isAtBottomRef` / `userHasScrolledRef` /
   `firstMsgIdRef` — that only re-sticks while the user is anchored
   at the bottom; reading history is left alone. Same semantic as
   iMessage and web ChatWindow.

Bonus alignment with web's bubble styling:
- User bubble: bg-muted (was bg-primary dark), max-w-[80%] (was 88%),
  text-foreground.
- Assistant: w-full (was self-start max-w-[88%]) so Markdown / code
  blocks / tables get the full content width.
- Outer content padding: px-4 pt-3 pb-4 gap-3 (was px-3 py-3 gap-2),
  matching web's `max-w-4xl px-5 py-4 space-y-4` rhythm at mobile scale
  and giving the last bubble breathing room above the composer.
- FlatList itself gets `className="flex-1"` so its height is the
  remaining viewport in the KeyboardAvoidingView column, matching web's
  `flex-1 overflow-y-auto` host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:52:53 +08:00
Naiyuan Qing
f53a6936e3 refactor(mobile): drop paginated timeline, fetch as single ASC list
Server-side timeline pagination was retired (#2322) because p99 issues
have ~30 entries — cursors were pure overhead and split reply threads
across page boundaries. Mobile mirrors the new shape:

- `api.listTimeline` returns `TimelineEntry[]` directly (was
  `TimelinePage` with `next_cursor` + `has_more_before`).
- `issueTimelineOptions` is a flat `queryOptions` (was
  `infiniteQueryOptions`); query consumers drop the page-walking dance.
- WS handlers `comment:created` / `activity:created` now `append`
  (oldest-first ASC list) instead of `prepend`. Mirror updater renamed.
- Timeline list view collapses to a single `FlatList data={entries}`,
  no more `pages.flat()` + `fetchNextPage` plumbing.

Mirrors web's post-#2322 `issueTimelineOptions` shape (per
apps/mobile/CLAUDE.md "mirror, don't import").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:51:36 +08:00
Naiyuan Qing
d42922020c chore(mobile): rename ios run scripts to ios:device, add .env.example, document commands
`expo run:ios` always meant device install in this project, but the
unqualified `ios` / `ios:mobile` script names invited confusion with the
simulator default. Rename to `ios:device` / `ios:device:staging` so the
intent is explicit, and pair with a checked-in `.env.example` so a fresh
clone knows which keys mobile needs. CLAUDE.md picks up the new command
list under the existing Commands section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:50:52 +08:00
Naiyuan Qing
c7eb366242 chore(mobile): add Issues entry to More popover
Wires the new workspace Issues page (more/issues.tsx) into GlobalNavMenu,
ordered above Projects (higher-frequency surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:02:50 +08:00
Naiyuan Qing
69bd280695 feat(mobile): workspace Issues page in More popover
New surface for the workspace-wide issue list. Mirrors web's IssuesPage
(packages/views/issues/components/issues-page.tsx) at mobile fidelity:
SectionList grouped by status, status + priority filter (reuses the
shared IssueFilterSheet), pull-to-refresh, empty/error states, IssueRow
identical to other surfaces.

Differs from My Issues by dropping the Assigned/Created/Agents scope tabs
(workspace-wide list has no per-user scope) and using an independent
view-store so filters don't bleed between the two pages.

Plumbing:
- data/queries/issues.ts → issueListOptions(wsId) using existing
  issueKeys.list(wsId) prefix (already wired into invalidations from
  mutations and project realtime).
- data/stores/issues-view-store.ts → status/priority filter state.
- data/realtime/use-issues-realtime.ts → list-level WS subscription;
  patches list(wsId) on issue:created (prepend) / updated / deleted,
  invalidates on reconnect. Mounted in <RealtimeSubscriptions />.
- data/realtime/issue-ws-updaters.ts → patchIssuesList /
  prependToIssuesList / removeFromIssuesList, plus extending
  patchIssueLabels to also patch list(wsId).
- workspace _layout: register more/issues Stack.Screen, drop Stack.Screen
  entries for the routes deleted in 5cc7f01 (favorites/initiatives/
  views/teams/notifications/pins).

Filters beyond status/priority (assignee/project/label/creator) are a
v1.1 follow-up; v1 ships at My Issues parity for code reuse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:02:31 +08:00
Naiyuan Qing
34a89f20dd refactor(mobile): extract shared IssueRow + props-driven filter sheet
- Add components/issue/issue-row.tsx as the single source for list-style
  issue rendering. `<IssueRow issue showStatus? />` — showStatus opt-in
  for ungrouped lists (project related-issues), default off where the
  SectionList header already shows status (my-issues).
- Replace the two inline IssueRow copies in (tabs)/my-issues.tsx and
  components/project/project-related-issues.tsx.
- Rename MyIssuesFilterSheet → IssueFilterSheet and replace store-coupled
  state with props so the same sheet can serve any view-store. My Issues
  call site passes useMyIssuesViewStore selectors as props.
- Rename filterMyIssues → filterIssues (function was already generic;
  the misnomer just reflected the original single call site).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:01:19 +08:00
Naiyuan Qing
5cc7f0178b feat(mobile): redesign More popover — user card + lean nav
- Add user identity card at top of GlobalNavMenu, mirroring web sidebar
  dropdown (packages/views/layout/app-sidebar.tsx:496). Tap pushes into
  the existing settings page where account / workspaces / sign-out
  already live.
- Trim NAV_ITEMS to Projects only. Inbox / My Issues / Chat are bottom
  tabs; Settings is reached via the user card.
- Delete six orphaned stub routes (favorites, initiatives, views, teams,
  notifications, pins) — no remaining external references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:21:45 +08:00
Naiyuan Qing
ad9ba9d790 feat(mobile): add projects feature with realtime cache sync
Mobile parity for the projects domain — browse, detail, create, edit,
delete, plus GitHub resource attach. UX adapted to iOS (Stack push +
modal sheets, picker sheets per property, ActionSheet for Edit/Delete,
collapsible Open/Done buckets in related issues) while preserving web's
semantics: 5 status enums (incl. cancelled), 5 priorities, lead supports
both members and agents, counts come from server fields.

Data layer follows mobile CLAUDE.md rules: parseWithFallback + signal
on every read, optimistic patch + WS event-always-wins on mutations,
mobile-owned ws-updaters (not imported from packages/core) that patch
over invalidate to honour the cellular-data rule. Per-record realtime
hook subscribes to issue:* events filtered by project_id so the
related-issues list stays fresh without pull-to-refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:22:26 +08:00
Naiyuan Qing
8285e90d78 feat(mobile): More tab opens global nav popover
Replaces the full-screen More tab with a bottom-bar trigger that opens a
popover containing the workspace switcher and 9 nav destinations
(Inbox, My Issues, Favorites, Projects, Initiatives, Views, Teams,
Settings, Search). Uses expo-router Tabs.Screen listeners.tabPress +
preventDefault — the more.tsx route is a stub that redirects to inbox
if hit directly. Custom Modal popover (no @gorhom/bottom-sheet) since
that lib still requires Reanimated v3 and mobile is on v4. Account info
+ workspace list + sign out moved into a dedicated Settings page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:02:17 +08:00
Naiyuan Qing
3cb4610304 fix(mobile): simplify create-issue layout, fix render loop
Reshape the new-issue modal into one vertical scrolling form
(title → description → property chips), matching the Apple
Reminders / Linear iOS pattern. Previously the chips sat sticky-
pinned above the keyboard, which made them invisible when the
keyboard was up and stranded at the bottom of an empty screen
when it was down — neither state served the user.

Drop the markdown toolbar and upload buttons from the modal:
mobile users almost never format markdown when creating an issue,
and attachment upload is deferred for this release. Removing them
also lets the form breathe vertically.

Fix the "Maximum update depth exceeded" loop that surfaced once
real data started flowing. Root cause was duplicate
useQuery(projectListOptions) subscribers in CreateFormAttributeRow
and ProjectPickerSheet on the same key, under React 19 strict
mode. Form now holds the full Project object lifted from the
picker, so only the picker queries the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:01:51 +08:00
Naiyuan Qing
3ca160c545 fix(mobile): drop double home-indicator padding under chat composer
chat.tsx wrote SafeAreaView edges={["top","bottom"]} while the parent
<Tabs> container already absorbs the home-indicator inset on behalf of
all tab screens. The result was ~34pt of empty space below the
composer. Sibling tabs (inbox / my-issues / more) all use
edges={["top"]} — chat was the outlier.

The gap only became visible after the floating-card composer landed;
the previous sticky-bar layout disguised it as bg-coloured padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:43:53 +08:00
Naiyuan Qing
c24e086612 fix(mobile): tap message area dismisses keyboard in chat
keyboardShouldPersistTaps="handled" on FlatList has a long-standing
RN bug (facebook/react-native#31448) that prevents the tap-to-dismiss
path from firing in many setups. Wrap ChatMessageList with a Pressable
that calls Keyboard.dismiss() — the canonical workaround documented
in the RN Keyboard guide and the Expo keyboard-handling guide.

Interactive drag-dismiss on the FlatList itself (the previous commit)
is an independent code path and continues to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:38:30 +08:00
Naiyuan Qing
fdc9f916f2 feat(mobile): iMessage-style keyboard dismiss in chat message list
Drag the list to interactively pull the keyboard down with the finger,
or tap empty space between bubbles to dismiss. `handled` keeps long-
press action sheets and other in-bubble Pressables firing normally.

Sending a message intentionally keeps the input focused so the user
can immediately type the next one — RN's default and the chat-app
standard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:25:24 +08:00
Naiyuan Qing
d2cee9741c fix(mobile): stable empty reference in viewed-issues selector
selectViewedIssueIds was returning a fresh `[]` when the workspace had
no entry yet, which made useSyncExternalStore see a different snapshot
on every read and trigger "getSnapshot should be cached" + infinite
re-render. Share a single frozen empty array for all no-entry paths,
matching the Zustand footgun rule in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:19:43 +08:00
Naiyuan Qing
6067a7a0d6 feat(mobile): @ in chat picks an issue (Recent + My issues)
In 1:1 user↔agent chat sessions, @member and @agent are noise (no
notification channel; the session is already bound to one agent).
Switch the mention bar to surface issues instead — Recent (most recent
5 from the in-memory viewed-issues store) followed by My issues
(assigned-to-me, max 10, deduped). The serialized token matches web
byte-for-byte ([MUL-XXX](mention://issue/<uuid>)) so the agent can read
the reference directly even though chat.go SendChatMessage doesn't yet
run ParseMentions — that's a follow-up.

MentionSuggestionBar gains a mode="comment"|"chat" prop; comment mode
is the default and preserves existing behaviour for the issue comment
composer and new-issue body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:51:10 +08:00
Naiyuan Qing
ee1c040071 feat(mobile): add issue MentionType + viewed-issues store
Extend MentionType with "issue" and serialize issue mentions without
the leading `@` in the link label, matching web's
mention-extension.ts:67-74. New in-memory LRU tracks recently viewed
issues per workspace so the chat composer can surface them next.

Issue detail screen pushes its id into the store on mount. Suggestion
bar UI lands in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:48:32 +08:00
Naiyuan Qing
2ac2609809 feat(mobile): redesign chat composer as floating card
Move chat input to a rounded card with inline @ and Send/Stop buttons
(Linear / iMessage idiom), dropping the markdown toolbar that comment-
composer needs but chat doesn't. Send stays visible-but-disabled when
there's no draft so the button row no longer jitters as the user types.
Adds SF Symbols, expo-haptics, and reanimated crossfade for send↔stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:34:23 +08:00
Naiyuan Qing
da1c7d5bc2 feat(mobile): add due_date / project to create-issue, drop agent toggle
Wire the last two CreateIssueRequest fields that have a meaningful UX on
mobile (due_date, project_id) to the new-issue form via two new chips
sharing the existing CreateFormAttributeRow + picker-sheet pattern.

Fixes a silent 400 on the existing detail-page due_date update: the
picker was emitting YYYY-MM-DD but server/internal/handler/issue.go
parses with time.Parse(time.RFC3339, ...) which rejects date-only. Now
sends full ISO, matching web's due-date-picker.tsx.

Removes the placeholder agent-mode toggle from new-issue — it was a
dead UI surface (logged to console on submit, never wired). Mobile's
create-issue is now manual-only, aligned with web's form semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:21:48 +08:00
Naiyuan Qing
3a439d97a1 feat(mobile): chat v1 — single-tab IA, optimistic send, two-tier WS
Fill the Chat tab placeholder. UX is mobile-native (top bar with tap-title
sheet, message list, bottom composer — no two-layer nav); logic is at
parity with web (API/events/has_unread/optimistic sequence/permissions/
enums all mirrored).

Includes:
- data layer: 8 chat API methods + zod schemas with .catch() enum drift
  fallback; queries / mutations (optimistic delete + markRead); per-
  session drafts store
- two-tier realtime: listing-level hook mounted in workspace _layout
  (chat:session_* + chat:done for has_unread), per-record hook mounted in
  the chat screen (chat:message/done + 5 task:* events, all filtered by
  chat_session_id, scoped reconnect invalidates); ws-updaters carry an
  invalidate fallback for pre-#2123 servers that omit chat:done payload
- rule mirrors: canAssignAgent, failureReasonLabel, agent availability
  three-state hook (mirror-not-import per apps/mobile/CLAUDE.md)
- UI: ChatHeader (tap title → SessionSheet) + ChatMessageList (FlatList,
  destructive bubble on failure_reason) + ChatComposer (mention +
  markdown toolbar minus file/image) + StatusPill (Thinking · Ns) +
  SessionSheet (with agent avatars + long-press delete) +
  AgentPickerSheet + NoAgentBanner

v1 cuts (deferred to v2): file upload, rename, Chat tab unread badge,
agent presence dot, task tool_use detail expansion, focus mode route
anchor, starter prompts, history pagination, mobile test infra.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:06:47 +08:00
Naiyuan Qing
a5c5b955df chore(mobile): merge main into feat/mobile-ios
Resolve pnpm-lock.yaml by regenerating from main + branch package.json.
2026-05-14 09:25:55 +08:00
Naiyuan Qing
0406ae3c45 fix(mobile): hard 30s fetch timeout + TanStack Query signal pass-through
Triggered by a real user-visible bug: the Inbox tab's pull-to-refresh
spinner sometimes stuck on indefinitely after returning the app to the
foreground. List items rendered normally underneath, but `isRefetching`
never flipped back to `false`.

Root cause: api.ts fetch() had no timeout, no AbortController, and
ignored caller-supplied signals. iOS suspends background apps and can
silently kill in-flight network tasks (facebook/react-native#35384,
#38711). When the app foregrounded, the suspended Promise neither
resolved nor rejected. TanStack Query saw a fetch already in flight
and would not start a replacement on invalidate — it just waited
forever on the dead Promise.

Fix is three layers (all three required — partial fix leaves a footgun):

1. api.ts fetch() — hard 30s timeout via manual AbortController +
   setTimeout. Hermes does not implement AbortSignal.timeout() /
   AbortSignal.any() (facebook/react-native#42042, livekit#4014), so
   composition is via addEventListener("abort", ...) forwarding. On
   timeout we throw an ApiError(message, status=0) so callers see a
   real error instead of a Promise-that-never-settles.

2. All read-side api methods now accept opts?: { signal?: AbortSignal }
   and forward to fetch(): listInbox, listWorkspaces, getMe, listMembers,
   listAgents, listIssues, getIssue, listTimeline, listLabels,
   listProjects. Mutations are unchanged — TanStack Query doesn't pass
   a signal to mutationFn.

3. All queryFn definitions in data/queries/* now destructure { signal }
   and forward it. The TanStack official cancellation guide states that
   the signal is aborted when a query becomes out-of-date or inactive,
   so this is the primary mechanism that unwedges stuck queries (the
   30s timeout is the safety net for cases where nothing else fires).

Already in place (untouched, but documented):
- query-client.ts wires focusManager ← AppState and onlineManager ←
  NetInfo per TanStack's React Native official guide. focusManager
  alone wasn't enough — when a fetch hangs, "focused = true" can't
  unstick the query without signal cancellation or timeout. The three
  pieces work together.

Docs (apps/mobile/CLAUDE.md):

New Lesson #5 captures all of the above with:
- The original symptom + root cause
- The three-part rule (timeout / api opts / queryFn destructure)
- Hermes-specific caveats with citations to the upstream issues
- A grep verification command future readers can run to enforce part 3

Verified:
- pnpm --filter @multica/mobile typecheck passes
- pnpm --filter @multica/mobile lint shows only pre-existing issues
  unrelated to this change
- grep -n "queryFn: () =>" apps/mobile/data/queries/*.ts returns zero
  matches (every queryFn destructures signal)

Sources cited in CLAUDE.md:
- TanStack Query Cancellation guide (tanstack.com/query/v5)
- TanStack Query React Native official guide (tanstack.com/query/v5)
- facebook/react-native#42042 (AbortSignal.timeout unavailable in Hermes)
- facebook/react-native#35384 (iOS background fetch failure)
- facebook/react-native#38711 (iOS background JS Timers don't fire)
- livekit/livekit#4014 (AbortSignal.any unavailable in React Native)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:12:05 +08:00
Naiyuan Qing
9718768a4f feat(mobile): unified input UX — mention hook, markdown toolbar, file upload
new-issue Description and Comment composer used to each carry their
own copy of mention state (mentioning / recomputeMentioning /
onChangeText / onSelectionChange / onAtButton / onSelectMention /
serialize), ~50 LOC of identical boilerplate per surface. The
description had no toolbar at all; the comment had a lone left-side
`@` button. Visually the two body inputs looked like different
products — description was bare text, comment was rounded-2xl
bg-secondary with a focus tint.

Three changes consolidate the body-input experience:

1. Shared mention pipeline. `useMentionInput()` in lib/use-mention-input.ts
   owns text / selection / markers / mentioning, plus handlers
   (onChangeText, onSelectionChange, onAtButtonPress), suggestion-bar
   props, `insertAtCursor`, `insertAtLineStart`, serialize, snapshot,
   restore, reset. Comment-composer and new-issue both consume it,
   killing the duplication.

2. Shared keyboard-bar markdown toolbar. Linear-iOS range: `@`, bullet
   list, checklist, code block, quote, image, file. All buttons are
   literal-character inserts via hook helpers — no WYSIWYG. Toggles
   like bold/italic are deliberately out of scope because RN TextInput
   can't render styled ranges inside the input; a real WYSIWYG would
   mean swapping to react-native-enriched and crossing an HTML <->
   markdown boundary, which is a separate decision.

3. File upload. `api.uploadFile(asset, { issueId?, commentId? })`
   mirrors web's `/api/upload-file` contract but takes the RN-shaped
   `{ uri, name, type }` payload and validates the response against
   a strict `AttachmentSchema` (no silent fallback — an empty `url`
   would put a broken link into the editor). `useFileAttach()` glues
   expo-image-picker / expo-document-picker into the toolbar's image
   and file buttons. Context follows web: comments pass issueId,
   not-yet-created issues pass nothing. MAX_FILE_SIZE is mirrored, not
   imported, per mobile CLAUDE.md.

Cleanup:
- `MOBILE_PLACEHOLDER_COLOR` + `MIN_BODY_INPUT_HEIGHT_PX` in
  components/ui/input-tokens.ts; six hardcoded `#a1a1aa` callers now
  reference the const.
- Description now sits in a rounded-2xl bg-secondary/40 container
  with a focus-tint border, visually matching the comment composer.
- app.config.ts gets `expo-image-picker` plugin with
  `photosPermission` set and `cameraPermission` / `microphonePermission`
  disabled — without this Info.plist string, calling the image picker
  hard-crashes on iOS 14+.

A dev-client rebuild is required (new native modules); existing
behaviour and read-only rendering are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:21:11 +08:00
Naiyuan Qing
230e939e53 fix(mobile): markdown segment spacing uses Yoga gap, not per-child margin
Two consecutive fenced code blocks (and code-image / image-image
combos) rendered with effectively zero gap on iOS — NativeWind 4
compiles `my-3` to `marginVertical: 12`, but Yoga's sibling margin
behaviour doesn't accumulate the way web CSS does. Result: a `my-3`
sibling pair landed at ~12px on the screen instead of 24px, and the
border-on-border made it look like the two blocks were glued.

Move the spacing from per-child `marginVertical` to a `gap-3` on the
markdown root `<View>`. Gap is layout-level (Yoga implements it
directly), independent of margin behaviour, and uniformly applies
between every segment pair — prose ↔ code, code ↔ code, image ↔ code,
etc. CodeBlock and MarkdownImage drop their `my-3` / `mb-3` since the
parent now owns the spacing.

Prose ↔ code reads as ~24px (prose's enriched-markdown
`paragraph.marginBottom` 12 + root gap 12), which is the comfortable
"new block" feel; code ↔ code reads as exactly 12px, which is the
"these are related" feel. Both improve on the previous 0–8px crunch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:20:46 +08:00
Naiyuan Qing
bc4594b091 feat(mobile): WS realtime coverage — issue detail / my issues / agent tasks
Previous iteration shipped issue creation but mobile only ran WS for
inbox. Anything else (issue detail, my-issues list, agent task progress)
was pull-refresh only. Cross-client edits, agents working in the
background, and concurrent user changes all required the user to
manually refresh.

This commit closes that gap so all four user-facing surfaces stay
live without input. Mobile now matches web/desktop in product
freshness, while keeping mobile-specific patterns (patch over
invalidate, per-screen mount, event-always-wins) that reflect cellular
and AppState constraints.

New (3 files):

- data/realtime/issue-ws-updaters.ts — mobile-owned cache patchers.
  Pure functions over QueryClient: patchIssueDetail, prependTimelineEntry,
  patchTimelineEntry, removeTimelineEntry, patchMyIssuesList,
  removeFromMyIssuesList, addCommentReaction, removeCommentReaction,
  addIssueReaction, removeIssueReaction, patchIssueLabels,
  commentToTimelineEntry. NOT imported from packages/core because web's
  updaters bind to web's issueKeys instance and target bucketed caches
  mobile doesn't have — see CLAUDE.md "Mobile-owned updaters" rule.

- data/realtime/use-issue-realtime.ts — per-issue subscriptions mounted
  by the detail screen. Subscribes to 11 issue/comment/activity/reaction
  events plus 6 task:* events for live agent progress. Every handler
  filters by issue_id so we ignore noise from other issues. Reconnect
  invalidates only this issue's detail + timeline (not a global sweep).
  On issue:deleted for the active id, runs onDeleted callback so the
  screen can router.back() rather than strand the user on a 404.

- data/realtime/use-my-issues-realtime.ts — listing-level subscriptions
  mounted globally. issue:created → invalidate myAll (we don't know
  scope/filter membership for a fresh issue). issue:updated → patch via
  setQueriesData across every cached scope/filter combination.
  issue:deleted → strip from every cached list. Reconnect → invalidate
  myAll.

Modified (2 files):

- app/(app)/[workspace]/_layout.tsx — RealtimeSubscriptions adds
  useMyIssuesRealtime alongside useInboxRealtime. Both are workspace-
  session lifetime.

- app/(app)/[workspace]/issue/[id].tsx — mounts useIssueRealtime(id)
  with router.back as the onDeleted callback.

Docs (apps/mobile/CLAUDE.md):

New top-level section "## Realtime / WebSocket strategy" before the
Lessons section. Documents:
- Three-layer stack (ws-client → realtime-provider → per-feature hooks)
- Mount strategy: list-level global vs per-record per-screen, and why
  mobile doesn't use a single centralized useRealtimeSync like web
- Patch over invalidate (cellular-data rule)
- Mobile-owned updaters (don't import packages/core/issues/ws-updaters)
- Event-always-wins conflict policy
- Per-hook reconnect scoping (no global invalidate sweep)
- Recipe for adding new event coverage

Out of scope (deferred):
- Workspace member events (Phase 3D) — wait until More tab adds a real
  members list
- "N new comments" floating banner — patch-only for now
- Push notifications (APNs) — requires server config + entitlement

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:47:57 +08:00
Naiyuan Qing
ab1fe4fa1f feat(mobile): new issue creation — Manual mode fully wired with @ mention
Mobile can now actually create issues. Phase 1 left submit as a
console.log stub; this iteration wires Manual mode end-to-end so an
issue typed on a phone lands in the backend and appears in the user's
my-issues list on next refresh.

Wire-up:
- api.createIssue(body) — POST /api/issues, mirroring server route at
  server/cmd/server/router.go:320. Matches the CreateIssueRequest type
  exported from @multica/core/types so payload shape agrees across
  clients.
- useCreateIssue() mutation in data/mutations/issues.ts — no optimistic
  insert (the my-issues list is status-bucketed + scope-filtered, so
  optimism needs bucket+scope decisions; invalidation is simpler and
  hosted-backend latency is sub-300ms). onSuccess invalidates myAll
  and inbox query keys.
- new-issue.tsx Manual panel: submit ↑ calls mutateAsync, dismisses on
  success, surfaces errors via Alert.alert with the form state preserved
  so the user can retry. Button shows a spinner during the in-flight
  request and all inputs are disabled.

@ mention in description (members + agents):
- Mirrors comment-composer.tsx pattern exactly — selection tracking,
  tokenAtCursor on every change/selection event, MentionSuggestionBar
  rendered above the chip row, insertMention on pick, markers list
  appended.
- Title input stays plain (web doesn't allow mentions in title; we
  mirror that).
- Wire format on submit: serializeMentions(description, markers) →
  `[@name](mention://type/id)` markdown. Recognised by:
    * server/internal/util/mention.go ParseMentions
    * packages/views/editor/extensions/mention-extension.ts (web Tiptap)
    * apps/mobile/components/issue/mention-chip.tsx (mobile timeline)
- Backend does NOT trigger inbox notifications for mentions in issue
  descriptions (only on comments — see server/internal/handler/comment.go
  ParseMentions call). Mobile doesn't need to send a separate mentioned_*
  field; the markdown alone is sufficient.

Header polish:
- SubmitIssueButton accepts a `loading` prop; renders ActivityIndicator
  in place of the ↑ glyph while pending. Defends against double-tap.
- ModalCloseButton's earlier "Cancel" text is now a ✕ icon in a circle
  to match the new-issue / search modal visual reference (Linear-style).

Agent mode unchanged — still a placeholder that console.logs and
dismisses. Phase 3 will wire the real agent picker, apiClient
.quickCreateIssue, and the daemon version gate.

Explicitly NOT in this commit (later phases):
- Markdown formatting toolbar (Phase 2C)
- Project / Labels / Due date / Parent chips (Phase 2D)
- Image / file attachments (Phase 2E)
- #MUL-42 issue references, @all mention
- Draft persistence, "Create Another" toggle
- Pre-fill from sub-issue entry, optimistic list insert
- Success toast (success path = silent dismiss; mobile has no toast
  component yet)

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:33:26 +08:00
Naiyuan Qing
d23d786724 feat(mobile): hybrid markdown — Shiki code + lightbox images, prose via enriched
react-native-enriched-markdown does not expose JS-level custom renderers
(issues #54, #232, #246), so syntax highlighting, tap-to-lightbox, and
copy buttons cannot live inside enriched. Maintainer-endorsed workaround
(#246): split markdown at those boundaries and render the leaves in
React.

splitMarkdown walks marked.lexer tokens and emits prose / code / image
segments. Each prose island gets its own EnrichedMarkdownText; code
blocks reuse the in-house CodeBlock (Shiki + copy + horizontal scroll);
images reuse MarkdownImage (expo-image + lightbox). Paragraph-embedded
images are promoted to block siblings, matching GitHub mobile and
Linear iOS.

Drops ~600 LOC of dead walker code (render-block, render-inline, ast,
link, mention-chip, key) that the previous engine swap left behind.

Visual polish for the hybrid output:
- inline code alpha 20% → 12%; enriched paints over the full line
  height and RN can't apply the padding/radius/0.85em that keep
  GitHub web's chip compact, so the web alpha reads too heavy here.
- new `code-surface` token (#e8e8eb), one step darker than `secondary`,
  plus a 1px `border-border` hairline. Code block now elevates inside
  both white issue bodies and grey comment cards.
- code block margin my-3 — breathing room both sides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:27:50 +08:00
Naiyuan Qing
6239f2c1f5 feat(mobile): 4-tab shell — Chat tab, More tab, single-row header, filter chips, modal stubs
Scaffolds the next phase of mobile so per-feature work has a clean shell
to fill into. No new business logic, no data fetching beyond what already
existed; this is layout + navigation only.

Tab restructure (3 → 4 tabs):
- Add Chat tab placeholder (will port web bottom-right chat widget logic).
- Rename Settings → More; convert to grouped iOS-style list with sections
  Workspace / Personal / Account / Workspaces, all SectionGroup + NavRow.
- Workspace switcher list inside More uses the same NavRow visual pattern
  (active row marks with checkmark, inactive shows chevron).

Header (single-row):
- ScreenHeader simplified to one row: large title left, right actions
  slot. Removed the second-row WS switcher idea — switcher only lives in
  More now (the global header would mix scope levels with global actions).
- New HeaderActions component holds the two global actions: search and
  create-issue. Wired into all 4 tabs.

My Issues filter relocation:
- Filter button moved out of the header right slot (was a scope-mismatch
  hazard — global header should not host tab-local controls). Now sits
  inline at the right end of the ScopeTabs row.
- New ActiveFilterChips row renders below ScopeTabs when filters are
  active; each chip is tap-to-clear. Mirrors iOS Mail/Things UX.

Stubs for next phase:
- [workspace]/new-issue.tsx and [workspace]/search.tsx as modal screens
  presented from HeaderActions. Both have a Cancel button (new
  ModalCloseButton) in headerLeft.
- More tab sub-pages: more/{projects,agents,pins,notifications}.tsx
  registered in [workspace]/_layout.tsx with native Stack headers.

Cross-cutting:
- lib/issue-status.ts exports PRIORITY_LABEL alongside STATUS_LABEL
  (used by the new filter chip row).
- All new code uses Ionicons from @expo/vector-icons; not adding
  lucide-react-native — see comment-composer.tsx for the reasoning.

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change; more/ subdirectory
checked against .gitignore per CLAUDE.md mobile rule 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:45:42 +08:00
Naiyuan Qing
90fd3ff86c wip(mobile): markdown engine swap to enriched-markdown + sprint progress
Bundles the markdown rendering overhaul plus in-flight mobile feature
work as a single WIP for review.

Markdown work (the new direction):
- Swap internal Markdown component from hand-rolled marked walker to
  react-native-enriched-markdown (Software Mansion, native md4c).
  Public API <Markdown content={...} /> unchanged; consumers untouched.
  Mention links degrade to colored links + onLinkPress routing.
- Pre-swap fixes that landed first: 3-layer inline code (later corrected),
  Shiki via react-native-shiki-engine wired (now bypassed; code retained
  for selective re-enable on code blocks), code block copy button with
  expo-clipboard + expo-haptics, inline SVG copy/check icons, header
  scale calibrated to Apple HIG, paragraph leading-6 for CJK, list
  bullet column 24->16, lineBreakStrategyIOS="hangul-word" on outer
  paragraph Text.
- Preprocess: <br> -> "  \n" (CommonMark HardBreak) so md4c respects
  intentional breaks without misreading bare \n.
- Drop the Expo Go compatibility constraint from CLAUDE.md and
  markdown-renderer-research.md (project runs on dev client).
- New apps/mobile/docs/markdown-renderer-research.md captures the
  RN nested-Text rendering constraints (#10775 / #45925 / #6728), the
  CJK amplification mechanism, the typography scale calibration, and
  every decision-log entry from the engine evolution.

Other in-flight mobile features included:
- Issue detail timeline polish, comment composer + action sheet,
  mention suggestion bar, emoji picker sheet, reaction bar.
- Status / priority / assignee / label / due date picker sheets.
- My Issues filter sheet + view store.
- Realtime layer (ws-client, realtime-provider, use-inbox-realtime).
- Data layer additions (queries, mutations, schemas, attribute chips).

Cross-package:
- packages/core/api/schemas.ts: export IssueSchema for mobile use.

Build: native rebuild required after pulling (enriched-markdown is
a native Fabric module).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:07:04 +08:00
Naiyuan Qing
48c1e3746e feat(mobile): standalone markdown renderer with mentions, files, images, lightbox
Replaces `<Text>{content}</Text>` placeholders in issue description and
comment body with a full markdown pipeline at apps/mobile/lib/markdown/.

Pipeline: preprocess → marked.lexer → AST transforms → RN component tree.
Uses `marked` (~30KB JS parser) for CommonMark+GFM tokens; renderer is
hand-written (~600 LoC) for full control over RN's text-in-text rules,
mention chips, file cards, and inline-image-to-block promotion.

Supported in this drop:
- Headings, paragraphs, lists (ordered/unordered/task), block quotes,
  hr, fenced code (no syntax highlight), strong/em/del/codespan, autolinks
- Mention chips: mention://member/<id>, mention://agent/<id>,
  mention://issue/<id> — name resolution via existing useActorLookup;
  issue tap navigates to /:slug/issue/:id
- File cards: !file[name](url) preprocessed to [📎 name](url) link;
  Linking.openURL hands off to system viewers (PDF, doc, share sheet)
- Inline images promoted to block siblings (AST pass) — marked always
  wraps `![]()` in paragraph and RN can't put Image inside Text
- Real aspect ratio via Image.getSize, expo-image for caching/transition,
  global LightboxProvider with react-native-image-viewing for tap-to-zoom
- Tables degrade to card-per-row with header:value pairs (mobile-friendly
  responsive pattern; horizontal scroll tables get lost on touch)
- Embedded HTML stripped before lexing: <br> → newline, comments removed,
  other tags peeled to inner text. Residual html tokens render muted

Cross-package: lifted preprocessMentionShortcodes to @multica/core/markdown
so mobile can import it (mobile may import pure functions from core; cannot
import from packages/ui per Sharing Principles). packages/ui/markdown
keeps its own synced copy with a cross-reference comment — packages/ui
cannot import from core (Package Boundary Rules), so two synced copies
is the cleanest path.

Drops the comment-card "📎 N attachments" placeholder; markdown rendering
covers inline images and !file[] cards. attachments[] is backend cleanup
metadata, not display content (matches web).

New deps: marked@18, expo-image@55, react-native-image-viewing@0.2.
All Expo Go compatible — no native modules added.

Plan: ~/.claude/plans/plan-dynamic-narwhal.md
Research: apps/mobile/docs/markdown-renderer-research.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:02:14 +08:00
Naiyuan Qing
bb281e7dbd feat(mobile): activity row parity with web — lead icon, coalesce badge, single-line
Activity rows previously showed a two-line `[verb] / [absolute time]` block
with no icons, mismatching web (issue-detail.tsx:1046-1100). This redesign
brings mobile in line:

- Single-line layout: [lead icon] [name] [verb...truncate] [×N] [time→]
- Contextual lead icon: StatusIcon(details.to) for status_changed,
  PriorityIcon(details.to) for priority_changed, inline Calendar SVG for
  due_date_changed, ActorAvatar(size=16) otherwise
- Relative time right-aligned (drops the made-up "Linear-style" absolute
  timestamp; web uses relative + hover tooltip, mobile keeps relative only
  for v1)
- Coalesce ×N badge for non-task actions; task_completed/failed already
  bake the count into their copy
- Whole row text-xs muted-foreground — activity is supposed to feel quiet
  next to comment bubbles
- FlatList contentContainer gap-3 owns row spacing; rows themselves drop
  their own py so spacing doesn't double up

Calendar icon is an inline 16-line react-native-svg primitive — avoids
adding lucide-react-native to the mobile baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:11:30 +08:00
Naiyuan Qing
af79a7fb4e feat(mobile): ApiClient capability set + issue detail v1 + lessons in CLAUDE.md
ApiClient hardening (data/api.ts):
- onUnauthorized callback wired in _layout.tsx — 401 clears token,
  workspace store, TanStack Query cache, replaces nav to /login.
  Idempotent via signingOutRef. Mirrors packages/core/api/client.ts
  handleUnauthorized.
- X-Request-ID per request (lib/request-id.ts)
- Structured logger: `[api] -> METHOD path (rid)` on start, `[api] <-
  STATUS path (rid, duration)` on end. console.error for 5xx,
  console.warn for 404, console.log for success.
- Zod parseWithFallback for listIssues + listTimeline (the only two
  endpoints with schemas in packages/core/api/schemas.ts today —
  matches web's current coverage; new schemas should land on the web
  side first and both clients pick them up).

Core export (packages/core/package.json):
- Add `./api/schemas` to exports map so mobile can import the shared
  Zod schemas + EMPTY_* fallbacks (pure data, on the mobile sharing
  whitelist per CLAUDE.md).

Issue detail v1 (app/(app)/[workspace]/issue/[id].tsx):
- Read issue + infinite-scroll timeline + comment composer
- Stack header shows MUL-XXX once detail loads
- Supporting files: data/queries/issues.ts, data/mutations/issues.ts,
  components/issue/{timeline-list,comment-composer,...},
  lib/{format-activity,timeline-coalesce,timeline-thread}.ts
- Property edits, reactions, mentions, image lightbox deferred to V2+

apps/mobile/CLAUDE.md — Lessons learned (encode into reflexes):
1. Install/upgrade deps: `pnpm view <pkg> dist-tags` first; `expo
   install` for Expo packages, never `pnpm add` blindly
2. New source subdirectory: `git check-ignore -v` to verify against
   root .gitignore generic rules (data/, build/, bin/); add !data/
   override if matched. Cost a 14-file missing commit before.
3. ApiClient capability list (Zod parse / 401 callback / X-Request-ID
   / structured logger) — all baseline, not polish
4. Visual alignment is baseline, not polish — tab icons, screen titles,
   right-column vertical alignment of trailing elements, type-aware
   secondary lines (mirror InboxDetailLabel, not raw item.body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:05:00 +08:00
Naiyuan Qing
a979124c21 fix(mobile): unignore data/ + dedup, layout, mark-read, SVG icons, issue page
Critical: previous commit (def9c08d) was missing apps/mobile/data/ entirely
because root .gitignore has a generic `data/` rule (for backend runtime
dirs) that swallowed mobile's source tree. Added !data/ override to
apps/mobile/.gitignore. The branch was running locally only because
untracked files still load at runtime.

Functional changes on top:

- Status icon: react-native-svg, 7 variants (backlog 16-dot ring / todo /
  in_progress 0.5 / in_review 0.75 / done + check / blocked + slash /
  cancelled + x). Geometry mirrors packages/views/issues/components/
  status-icon.tsx (14x14 viewBox, OUTER_R=6, FILL_R=3.5)
- Priority icon: 4 ascending bars + "none" horizontal dash; mirrors web
  priority-icon.tsx. Urgent pulse animation deferred.
- Inbox row click: optimistic mark-read (mirrors packages/core/inbox/
  mutations.ts useMarkInboxRead) + router.push to /[ws]/issue/[id]
- My Issues row click: router.push to /[ws]/issue/[id]
- /[ws]/issue/[id] placeholder with native iOS Stack header + back
  button + edge-swipe-to-dismiss
- Inbox layout: title-row right edge = StatusIcon, body-row right edge
  = timeAgo, vertically aligned (matches web inbox-list-item.tsx)
- InboxDetailLabel mobile mirror at components/inbox/detail-label.tsx —
  type-aware second-line ("Set status to (icon) Done" / "Mentioned" /
  "Assigned to <name>" etc.). Was rendering raw markdown body which
  leaked ## heading prefixes.
- Inbox dedup: deduplicateInboxItems mirrored into apps/mobile/lib/
  inbox-display.ts (filter archived -> group by issue_id -> keep newest
  -> sort desc). Without it mobile rendered 3 unread dots while web
  sidebar showed "Inbox 1". Documented in apps/mobile/CLAUDE.md
  "Behavioral parity" with the lesson: before rendering ANY list-shaped
  API response, mirror every preprocessing step web/desktop runs
  between useQuery and JSX (dedupe / coalesce / filter / display
  helpers). Backend returns raw cache shape; client shapes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:38:25 +08:00
Naiyuan Qing
def9c08d35 feat(mobile): v1 shell — auth, workspace switching, inbox + my-issues
- Auth: email OTP login mirroring packages/core/auth/store.ts behavior
  (401 clears token, non-401 preserves; token written only on verify
  success); expo-secure-store with key "multica_token" matching desktop
- Workspace context: /[workspace]/ URL slug as source of truth (deep-
  link friendly), ApiClient auto-injects X-Workspace-Slug, SecureStore
  persists last-selected slug for cold-start restore
- Bottom tabs (Ionicons): Inbox / My Issues / Settings
- Inbox: actor avatar, unread brand-dot, status icon, time-ago + body
  subtitle. getInboxDisplayTitle mirrored from packages/views/inbox/
  components/inbox-display.ts
- My Issues: priority bars (matching IssuePriority bar counts from
  packages/core/issues/config/priority.ts), status dot, identifier,
  title, assignee avatar
- Settings: account info + workspace switcher; switching replaces nav
  to /[newSlug]/inbox so back stack doesn't trail to old workspace
- Multi-env: .env.staging / .env.production / .env.development.local
  with EXPO_PUBLIC_API_URL; APP_ENV in app.config.ts swaps
  bundleIdentifier so dev/staging/prod coexist on a device
- Build: dev:mobile + dev:mobile:staging scripts; main turbo
  build/typecheck/lint/test filter excludes @multica/mobile

Tech-stack (locked in apps/mobile/CLAUDE.md):
- Expo SDK 55, RN 0.83.6, React 19.2.0 (pinned, NOT catalog)
- NativeWind 4 + Tailwind 3.4 (intentional mismatch w/ web's Tailwind 4;
  visual tokens transcribed by hand from packages/ui/styles/tokens.css)
- TanStack Query 5 with AppState focus listener; Zustand 5

Not in this commit (intentional): issue detail page, mark-read mutation,
pull-to-refresh polish — next iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:14:38 +08:00
Naiyuan Qing
518d342021 docs(mobile): establish independence rules and tech-stack baseline
- Refactor root CLAUDE.md sharing rules into a single Sharing Principles
  section, replacing scattered mentions across 10 places with one source
  of truth + minimal "(web + desktop)" qualifiers on existing sections
- Add apps/mobile/CLAUDE.md with locked tech-stack baseline: Expo SDK 54,
  React Native 0.81, NativeWind 4 + Tailwind 3.4, react-native-reusables,
  TanStack Query 5, Zustand, expo-secure-store
- Mobile pins React directly (does NOT track root catalog:) so the Expo
  SDK / RN release schedule isn't blocked by web/desktop upgrades
- Visual tokens are mobile-owned (transcribed from packages/ui/styles/
  tokens.css by hand, not imported); Tailwind v3.4 vs v4 mismatch makes
  file sharing impractical anyway
- Document mobile build/release pipeline (main CI excludes mobile,
  separate mobile-verify and mobile-release workflows, EAS Update for OTA)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:23:31 +08:00
797 changed files with 87036 additions and 10335 deletions

View File

@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.

View File

@@ -29,6 +29,22 @@ PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
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
# localhost dev — the frontend will compose the URL from
# window.origin + webhook_path in that case. Headers are intentionally
# not used to derive this value, to avoid Host / X-Forwarded-Host
# spoofing when a self-hosted reverse proxy is not hardened.
MULTICA_PUBLIC_URL=
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
# Empty (the default) means "trust no headers" — the limiter uses
# r.RemoteAddr only, which is the safe shape when the backend is
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
# announced ranges for cloud deployments.
MULTICA_TRUSTED_PROXIES=
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
MULTICA_DAEMON_ID=
@@ -96,6 +112,13 @@ CLOUDFRONT_DOMAIN=
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
# "8760h", "720h30m") or plain integer seconds.
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
# set a longer value to reduce re-authentication frequency.
# Note: longer TTL = longer exposure window if a cookie is leaked.
# AUTH_TOKEN_TTL=2592000
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
@@ -103,8 +126,30 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
CORS_ALLOWED_ORIGINS=
# ==================== Rate limiting (optional Redis) ====================
# Per-IP fixed-window rate limiter on the public auth endpoints
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
# backend logs "rate limiting disabled: REDIS_URL not configured" at
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
# the PAT cache, and the daemon-token cache.
# REDIS_URL=redis://localhost:6379/0
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5
# RATE_LIMIT_AUTH_VERIFY=20
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
# REQUIRED behind a reverse proxy — otherwise every real user shares
# the proxy IP and the whole deployment lands in one bucket, turning
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
# governs the autopilot webhook limiter).
# RATE_LIMIT_TRUSTED_PROXIES=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
@@ -116,7 +161,7 @@ ALLOWED_ORIGINS=
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → Integrations "Connect GitHub")
# GitHub App integration (Settings → GitHub "Connect GitHub")
# Both must be set for the Connect button to enable and for webhooks to be
# accepted; leave empty to disable the integration. See docs/github-integration.
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.

View File

@@ -40,7 +40,7 @@ Closes #
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`) and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge

View File

@@ -91,3 +91,20 @@ jobs:
- name: Test
run: cd server && go test ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
# backend job so installer regressions surface independently, and
# exercised on macOS too because the installer targets macOS/Homebrew
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Test shell installers
run: bash scripts/install.test.sh

8
.gitignore vendored
View File

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

View File

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

View File

@@ -269,21 +269,37 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
## Workspaces
### Working with multiple workspaces
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
1. `--workspace-id <id>` flag on the command
2. `MULTICA_WORKSPACE_ID` environment variable
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
### List Workspaces
```bash
multica workspace list
multica workspace list --full-id
multica workspace list --output json
```
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
### Watch / Unwatch
### Switch Default Workspace
```bash
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
multica workspace switch <workspace-id>
multica workspace switch <slug>
```
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
### Get Details
```bash
@@ -291,10 +307,12 @@ multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
Passing no `<workspace-id>` resolves to the current default workspace, so `multica workspace get` doubles as "what workspace am I on?".
### List Members
```bash
multica workspace members <workspace-id>
multica workspace member list <workspace-id>
```
## Issues
@@ -310,7 +328,14 @@ multica issue list --full-id
multica issue list --limit 20 --output json
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
```bash
multica issue list --metadata pipeline_status=waiting_review
multica issue list --metadata pr_number=482 --metadata is_blocked=true
```
### Get Issue
@@ -326,7 +351,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace member list --output json` / `multica agent list --output json`.
### Update Issue
@@ -355,9 +380,44 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
### Comments
```bash
# List comments
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
# long-running issues prefer one of the thread-aware reads below to keep
# context windows tight.
multica issue comment list <issue-id>
# Single thread (root + every descendant). Anchor may be the root itself
# or any reply inside the thread — the server walks up to the root.
multica issue comment list <issue-id> --thread <comment-id>
# Single thread, capped to the N most recent replies. The thread root is
# always included (even with --tail 0), so an agent landing on a long
# thread keeps the "what is this about" context without dragging hundreds
# of replies into its prompt.
multica issue comment list <issue-id> --thread <comment-id> --tail 30
# Scroll older replies inside the same thread. --before / --before-id are
# the reply cursor that the previous response emitted on stderr as
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--before <ts> --before-id <reply-id>
# Most recently active threads (root + every descendant), grouped by
# thread. Returns N complete conversational arcs, oldest-active first so
# the freshest thread sits closest to "now" in an agent prompt.
multica issue comment list <issue-id> --recent 20
# Scroll older threads. Under --recent, --before / --before-id are a
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
# `Next thread cursor: --before <ts> --before-id <root-id>`.
multica issue comment list <issue-id> --recent 20 \
--before <ts> --before-id <root-id>
# Incremental polling. Combines with --thread or --recent; filters out
# replies created on or before <ts> from the page (the thread root is
# exempt so the agent always gets context).
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--since <RFC3339-timestamp>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
@@ -368,6 +428,56 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
**`--before` / `--before-id` semantics depend on the paging mode**, by
design — same flag, different scope:
| Mode | What the cursor walks | stderr label |
| --- | --- | --- |
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
Outside those two modes (`--thread` without `--tail`, or no `--thread`
and no `--recent`) the cursor flags are rejected so they cannot silently
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
`X-Multica-Next-Before-Id`) only when an older page actually exists —
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
replies) intentionally return no cursor so callers stop paginating.
When `--since` is combined with `--recent` or `--thread --tail`, the
server additionally suppresses the cursor once the cursor target itself
is older than `since`. Older pages walk strictly older rows, so they
cannot satisfy `> since` either — emitting a cursor there would just
hand back root-only pages until the caller reaches the start of the
thread / issue. Incremental polling stops at the first page whose
cursor target falls before the watermark.
### Metadata
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
```bash
# List every key on an issue
multica issue metadata list <issue-id>
# Read a single key
multica issue metadata get <issue-id> --key pipeline_status
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
multica issue metadata set <issue-id> --key pr_number --value 482
multica issue metadata set <issue-id> --key is_blocked --value true
# Force a specific type when sniffing would pick the wrong one
multica issue metadata set <issue-id> --key code --value 42 --type string
# Remove a key
multica issue metadata delete <issue-id> --key pipeline_status
```
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
### Subscribers
```bash
@@ -508,6 +618,8 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).

View File

@@ -18,6 +18,7 @@ ARG COMMIT=unknown
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
# --- Runtime stage ---
FROM alpine:3.21
@@ -29,6 +30,7 @@ WORKDIR /app
COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh

View File

@@ -142,6 +142,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica workspace list` | List your workspaces (current is marked with `*`) |
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |
@@ -185,3 +187,5 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
An iOS mobile client lives in [`apps/mobile/`](apps/mobile/) — see its [README](apps/mobile/README.md) for how to build it onto your own iPhone.

View File

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

View File

@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |

View File

@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
@@ -189,22 +190,67 @@ function createWindow(): void {
return { action: "deny" };
});
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
// Window-level keyboard shortcuts. Calling preventDefault here prevents
// both the renderer keydown AND the application menu accelerator, so
// anything we own here (reload-block, zoom) is the sole handler for
// that combination — no double-fire with the macOS default View menu.
mainWindow.webContents.on("before-input-event", (event, input) => {
if (handleAppShortcut(input, mainWindow!.webContents)) {
event.preventDefault();
}
});
// Dev-mode renderer diagnostics. When the renderer crashes hard enough
// that DevTools can't be opened (white screen with no clickable surface),
// the only way to recover the actual JS error is to forward it from the
// main process to the terminal running `make dev`. Without these, the
// user sees only the daemon-manager polling noise (`Render frame was
// disposed before WebFrameMain could be accessed`) which is a downstream
// symptom, not the cause.
//
// Gated by `is.dev` to keep production stderr clean — packaged builds
// don't have a terminal anyway, and we ship to crash-reporting separately.
if (is.dev) {
const log = (tag: string, ...args: unknown[]) =>
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
// Forward every renderer-side console.* call. The detail object also
// carries source URL + line — included so a thrown stack trace from
// window.onerror is traceable back to a file.
mainWindow.webContents.on("console-message", (details) => {
const { level, message, sourceId, lineNumber } = details;
log(level, `${message} (${sourceId}:${lineNumber})`);
});
// Fires when the renderer process dies for any reason (OOM, crash,
// killed). `details.reason` is the discriminator: "crashed", "oom",
// "killed", "abnormal-exit", "launch-failed", etc.
mainWindow.webContents.on("render-process-gone", (_event, details) => {
log("process-gone", JSON.stringify(details));
});
// Fires when loadURL / loadFile can't reach its target (dev server
// not up yet, network blip, file missing). errorCode is a Chromium
// net error number; -3 = ABORTED is normal during HMR and skipped.
mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (errorCode === -3) return;
log(
"did-fail-load",
`code=${errorCode} desc=${errorDescription} url=${validatedURL} mainFrame=${isMainFrame}`,
);
},
);
// Fires when the preload script throws before the renderer can boot.
// This is the one error class that NEVER reaches DevTools (preload
// runs before any window) — without this listener it's invisible.
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
});
}
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
function makeWc(initialLevel = 0) {
let level = initialLevel;
return {
getZoomLevel: vi.fn(() => level),
setZoomLevel: vi.fn((next: number) => {
level = next;
}),
currentLevel: () => level,
};
}
function key(
k: string,
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
): ShortcutInput {
return {
type: "keyDown",
key: k,
control: false,
meta: false,
...mods,
};
}
describe("handleAppShortcut — reload blocking", () => {
it("swallows Cmd+R on macOS", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("swallows Ctrl+R on Linux/Windows", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
});
it("swallows F5 regardless of modifier", () => {
const wc = makeWc();
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
});
it("ignores non-keyDown events", () => {
const wc = makeWc();
expect(
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
).toBe(false);
});
});
describe("handleAppShortcut — zoom in", () => {
it("zooms in on Cmd+= (unshifted)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Cmd++ (Shift+=)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Ctrl+= on non-mac", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("clamps zoom-in at the upper bound", () => {
const wc = makeWc(4.5);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(4.5);
});
});
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
it("zooms out on Cmd+- (unshifted)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Cmd+_ (Shift+-)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Ctrl+- on non-mac", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("undoes a prior Cmd+= so the user can return to 100%", () => {
const wc = makeWc(0);
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0.5);
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0);
});
it("clamps zoom-out at the lower bound", () => {
const wc = makeWc(-3);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(-3);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — reset zoom", () => {
it("resets to 0 on Cmd+0", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("resets to 0 on Ctrl+0", () => {
const wc = makeWc(-1.5);
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("ignores plain 0 without modifier", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — unrelated keys pass through", () => {
it("does not capture plain letters", () => {
const wc = makeWc();
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import type { WebContents } from "electron";
// Shape of the input subset we read from Electron's `before-input-event`.
// Modeled as a structural type so the handler is unit-testable without a
// real Electron Input instance.
export type ShortcutInput = {
type: string;
key: string;
control: boolean;
meta: boolean;
};
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
// per step). Clamp to a range that keeps the UI legible — values outside
// this band turn the workspace into either confetti or a microfiche.
const ZOOM_STEP = 0.5;
const ZOOM_MIN = -3;
const ZOOM_MAX = 4.5;
/**
* Inspect a `before-input-event` key and apply (or block) the matching
* window-level shortcut. Returns `true` when the caller should call
* `event.preventDefault()` — that both swallows the renderer keydown and
* prevents the application menu accelerator from firing, so we don't
* double-trigger zoom on macOS where the default menu also binds these
* keys.
*
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
* default `Cmd+-` accelerator does not fire reliably across keyboard
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
* Handling the shortcuts here gives identical behavior on every platform
* and every layout.
*/
export function handleAppShortcut(
input: ShortcutInput,
webContents: ZoomTarget,
platform: NodeJS.Platform = process.platform,
): boolean {
if (input.type !== "keyDown") return false;
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
// (tabs, drafts, WS connections) with no URL bar to recover from.
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
return true;
}
if (!cmdOrCtrl) return false;
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
if (input.key === "=" || input.key === "+") {
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
if (input.key === "-" || input.key === "_") {
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + 0 → reset zoom to 100%.
if (input.key === "0") {
webContents.setZoomLevel(0);
return true;
}
return false;
}

View File

@@ -3,9 +3,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { pickLocale } from "@multica/core/i18n";
import { useAuthStore } from "@multica/core/auth";
import { useWelcomeStore } from "@multica/core/onboarding";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { setCurrentWorkspace } from "@multica/core/platform";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -118,25 +120,31 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
// judgment in callback / login:
// un-onboarded:
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// already onboarded:
// zero workspaces → /workspaces/new overlay
// ≥1 workspaces → no overlay, fall through to dashboard
// Pre-workspace overlay routing for desktop. Mirrors the web layout
// hard gate via overlays (desktop has no URL bar, so we open the
// onboarding overlay instead of router.replace):
// onboarded + has workspace → no overlay, dashboard
// un-onboarded (any wsCount):
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// onboarded + no workspace → /workspaces/new overlay
//
// The "un-onboarded but in workspace" state is now physically impossible
// because backend transactions atomically set onboarded_at when a user
// joins the `member` table. Anyone with workspaces is by definition
// onboarded.
// V3 invariant: `onboarded_at != null` is the only path into the
// dashboard. CreateWorkspace does not mark onboarded; only Step 3's
// CompleteOnboarding (and AcceptInvitation) flip the flag. A user who
// somehow has a workspace but no onboarded mark must be sent back to
// /onboarding — we also clear the active workspace so the dashboard
// doesn't render under the overlay with stale workspace context.
useEffect(() => {
if (!user || !workspaceListFetched) return undefined;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return undefined;
if (wsCount > 0) return undefined;
if (hasOnboarded && wsCount > 0) return undefined;
if (!hasOnboarded) {
// Stale workspace context (if any) would leak X-Workspace-Slug
// headers into onboarding-time API calls. Clear it before opening
// the overlay.
setCurrentWorkspace(null, null);
// Look up pending invitations by email. Network blip is non-fatal —
// fall through to onboarding so the user isn't stuck on a blank
// window. The sidebar's pending-invitations dropdown will surface
@@ -258,6 +266,9 @@ function BlockingRuntimeConfigError({ message }: { message: string }) {
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
// Drop any post-onboarding welcome signal so user B logging in next
// doesn't inherit user A's pending modal state.
useWelcomeStore.getState().reset();
try {
await window.daemonAPI.clearToken();
} catch {

View File

@@ -13,7 +13,6 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
@@ -169,7 +168,6 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>

View File

@@ -19,10 +19,28 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
// Remember the last known daemonId/deviceName. After the daemon is
// stopped, `status.daemonId` goes back to undefined — without this
// sticky cache the local row would either disappear or get reclassified
// as a remote machine (since `isCurrent` requires a daemonId match),
// taking the Start button with it.
const [lastIdentity, setLastIdentity] = useState<{
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
useEffect(() => {
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
const apply = (s: DaemonStatus) => {
setStatus(s);
if (s.daemonId) {
setLastIdentity({
daemonId: s.daemonId,
deviceName: s.deviceName ?? null,
});
}
};
window.daemonAPI.getStatus().then(apply);
return window.daemonAPI.onStatusChange(apply);
}, []);
const bootstrapping =
@@ -32,9 +50,14 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
localDaemonId={status.daemonId ?? null}
localMachineName={status.deviceName ?? null}
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName}
localMachineActions={<DaemonRuntimeActions />}
// Desktop owns a local machine for the lifetime of the app, even
// while the daemon is stopped or hasn't registered yet. The shared
// page synthesizes a placeholder local row when no real runtime
// matches, so the Start button is always reachable.
hasLocalMachine
bootstrapping={bootstrapping}
/>
);

View File

@@ -116,7 +116,7 @@ describe("PageviewTracker", () => {
expect(state.capturePageview).not.toHaveBeenCalled();
});
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
it("fires pageview when a foreground tab is added (addTab path)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
@@ -128,7 +128,11 @@ describe("PageviewTracker", () => {
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
// Simulate a foreground new-tab action (e.g. an explicit "Open in new
// tab" toolbar button that passes `{ activate: true }`) — tC is
// appended AND becomes active. `openInNewTab` defaults to background
// (no `setActiveTab`); only the `activate: true` branch produces the
// state change this test exercises.
state.byWorkspace = {
acme: {
activeTabId: "tC",

View File

@@ -0,0 +1,151 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, fireEvent, within } from "@testing-library/react";
type MockTab = {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
};
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
] as MockTab[],
},
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
togglePin: vi.fn<(tabId: string) => void>(),
closeTab: vi.fn<(tabId: string) => void>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
moveTab: vi.fn<(from: number, to: number) => void>(),
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
}));
vi.mock("@/stores/tab-store", () => {
const store = {
get activeWorkspaceSlug() {
return state.activeWorkspaceSlug;
},
get byWorkspace() {
return state.byWorkspace;
},
togglePin: state.togglePin,
closeTab: state.closeTab,
setActiveTab: state.setActiveTab,
moveTab: state.moveTab,
addTab: state.addTab,
};
const useTabStore = Object.assign(
(selector?: (s: typeof store) => unknown) =>
selector ? selector(store) : store,
{ getState: () => store },
);
const useActiveGroup = () =>
state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
: null;
const resolveRouteIcon = () => "ListTodo";
return { useTabStore, useActiveGroup, resolveRouteIcon };
});
vi.mock("@multica/core/paths", () => ({
paths: {
workspace: (slug: string) => ({
issues: () => `/${slug}/issues`,
}),
},
}));
import { TabBar } from "./tab-bar";
function reset() {
state.activeWorkspaceSlug = "acme";
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
],
},
};
state.togglePin.mockReset();
state.closeTab.mockReset();
state.setActiveTab.mockReset();
state.moveTab.mockReset();
state.addTab.mockReset();
}
beforeEach(reset);
describe("TabBar hover action buttons", () => {
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getAllByLabelText } = render(<TabBar />);
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
});
it("clicking the Pin button calls togglePin for the tab", () => {
const { getAllByLabelText } = render(<TabBar />);
const pinButtons = getAllByLabelText("Pin tab");
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
expect(state.togglePin).toHaveBeenCalledWith("tB");
});
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
fireEvent.click(getByLabelText("Unpin tab"));
expect(state.togglePin).toHaveBeenCalledWith("tA");
});
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { queryAllByLabelText } = render(<TabBar />);
// Only the unpinned tab exposes a Close affordance — pinned tab requires
// explicit Unpin first (RFC §3 D3c FINAL).
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
});
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
});
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
const unpinnedTab = getByLabelText("Projects");
// lucide-react renders the icon name into the class list. The leading
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
// so we qualify on size to avoid matching the action glyph.
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
});
});

View File

@@ -1,3 +1,4 @@
import { Fragment } from "react";
import {
Inbox,
CircleUser,
@@ -8,6 +9,8 @@ import {
Settings,
X,
Plus,
Pin,
PinOff,
type LucideIcon,
} from "lucide-react";
import {
@@ -28,8 +31,20 @@ import {
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@multica/ui/components/ui/context-menu";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import {
useTabStore,
useActiveGroup,
resolveRouteIcon,
type Tab,
} from "@/stores/tab-store";
import { paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
@@ -42,9 +57,23 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Settings,
};
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
function SortableTabItem({
tab,
isActive,
isOnly,
}: {
tab: Tab;
isActive: boolean;
/**
* True iff this is the only tab in the workspace. Hiding X on the last
* tab matches existing behavior and avoids the surprise of the store's
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
*/
isOnly: boolean;
}) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const togglePin = useTabStore((s) => s.togglePin);
const {
attributes,
@@ -55,7 +84,11 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
isDragging,
} = useSortable({ id: tab.id });
const Icon = TAB_ICONS[tab.icon];
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
// present in the title, and this avoids a hard left accent border that read
// as visually heavy in light mode.
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
const style = {
transform: CSS.Transform.toString(transform),
@@ -74,17 +107,30 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
closeTab(tab.id);
};
const stopDragOnClose = (e: React.PointerEvent) => {
const handleTogglePin = (e: React.MouseEvent) => {
e.stopPropagation();
togglePin(tab.id);
};
const stopDragOnAction = (e: React.PointerEvent) => {
e.stopPropagation();
};
return (
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
// reachable via the hover action button below and the right-click menu.
const showCloseButton = !tab.pinned && !isOnly;
const tabButton = (
<button
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
@@ -94,7 +140,7 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
isDragging && "opacity-60",
)}
>
{Icon && <Icon className="size-3.5 shrink-0" />}
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
style={{
@@ -104,10 +150,22 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
>
{tab.title}
</span>
{!isOnly && (
<span
onClick={handleTogglePin}
onPointerDown={stopDragOnAction}
role="button"
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
title={tab.pinned ? "Unpin tab" : "Pin tab"}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
</span>
{showCloseButton && (
<span
onClick={handleClose}
onPointerDown={stopDragOnClose}
onPointerDown={stopDragOnAction}
role="button"
aria-label="Close tab"
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
<X className="size-2.5" />
@@ -115,6 +173,36 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
)}
</button>
);
return (
<ContextMenu>
<ContextMenuTrigger render={tabButton} />
<ContextMenuContent>
<ContextMenuItem onClick={() => togglePin(tab.id)}>
{tab.pinned ? (
<>
<PinOff />
Unpin tab
</>
) : (
<>
<Pin />
Pin tab
</>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={tab.pinned || isOnly}
onClick={() => closeTab(tab.id)}
>
<X />
Close tab
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function NewTabButton() {
@@ -155,12 +243,17 @@ export function TabBar() {
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const pinnedCount = tabs.filter((t) => t.pinned).length;
const unpinnedCount = tabs.length - pinnedCount;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
// The store clamps the destination to within the source tab's zone
// (pinned vs unpinned), so this call is safe even when the user tries
// to drag across the boundary — the tab will land at the boundary.
if (from !== -1 && to !== -1) moveTab(from, to);
};
@@ -173,13 +266,22 @@ export function TabBar() {
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SortableTabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
{tabs.map((tab, index) => (
<Fragment key={tab.id}>
<SortableTabItem
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
{tab.pinned &&
index === pinnedCount - 1 &&
unpinnedCount > 0 && (
<div
aria-hidden
className="mx-1 h-4 w-px bg-border"
/>
)}
</Fragment>
))}
</SortableContext>
</DndContext>

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { useT } from "@multica/views/i18n";
type CheckState =
| { status: "idle" }
@@ -10,6 +11,7 @@ type CheckState =
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const { t } = useT("settings");
const [state, setState] = useState<CheckState>({ status: "idle" });
const currentVersion = window.desktopAPI.appInfo.version;
@@ -29,17 +31,15 @@ export function UpdatesSettingsTab() {
return (
<div>
<h2 className="text-lg font-semibold">Updates</h2>
<h2 className="text-lg font-semibold">{t(($) => $.desktop.updates.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
The desktop app checks for new versions automatically once an hour and
shortly after launch, downloading them in the background. You&apos;ll
be prompted to restart once an update is ready.
{t(($) => $.desktop.updates.description)}
</p>
<div className="mt-6 divide-y">
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Current version</p>
<p className="text-sm font-medium">{t(($) => $.desktop.updates.current_version)}</p>
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
v{currentVersion}
</p>
@@ -48,23 +48,20 @@ export function UpdatesSettingsTab() {
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm font-medium">{t(($) => $.desktop.updates.check_section_title)}</p>
<p className="text-sm text-muted-foreground mt-0.5">
Trigger a check now instead of waiting for the next automatic
poll. Available updates download in the background and show a
restart prompt when ready.
{t(($) => $.desktop.updates.check_section_description)}
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<Check className="size-3.5 text-success" />
You&apos;re on the latest version.
{t(($) => $.desktop.updates.up_to_date)}
</p>
)}
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
v{state.latestVersion} is downloading in the background
you&apos;ll be notified when it&apos;s ready to install.
{t(($) => $.desktop.updates.downloading, { version: state.latestVersion })}
</p>
)}
{state.status === "error" && (
@@ -84,10 +81,10 @@ export function UpdatesSettingsTab() {
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking
{t(($) => $.desktop.updates.checking)}
</>
) : (
"Check now"
t(($) => $.desktop.updates.check_now)
)}
</Button>
</div>

View File

@@ -62,18 +62,25 @@ function WindowOverlayInner() {
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {
onComplete={(ws, issueId) => {
close();
// Post-onboarding landing is always the workspace issues
// list. The welcome-issue flow moved into a dialog that
// renders on that page (StarterContentPrompt), so the
// flow doesn't need to thread a target issue id back here.
if (ws) {
// Runtime-connected onboarding lands on its single guide
// issue. Runtime-less exits still land on the issues list.
if (ws && issueId) {
push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
push(paths.workspace(ws.slug).issues());
} else {
push(paths.root());
}
}}
// Restart the bundled daemon when the user hits Refresh on
// Step 3. The daemon's PATH probe runs once at boot, so a
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
// up until the daemon is bounced.
onRuntimeRefresh={async () => {
await window.daemonAPI?.restart?.();
}}
/>
)}
</div>

View File

@@ -9,8 +9,10 @@ import {
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
import { WorkspacePresencePrefetch } from "@multica/views/layout";
import { useTabStore } from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
@@ -34,6 +36,15 @@ export function WorkspaceRouteLayout() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// While a WindowOverlay is open (onboarding, accept-invite, new-workspace),
// the underlying tab is still mounted in the React tree — so this layout
// and its WelcomeAfterOnboarding Modal would render UNDER the overlay.
// Because the modal uses a Portal that targets document.body, it ends up
// rendered LATER in the DOM and visually outranks the overlay's z-50.
// Suppress the modal whenever any overlay is active; the moment the
// overlay closes the welcome hook re-evaluates and pops if its store
// signal is still set.
const overlayActive = useWindowOverlayStore((s) => s.overlay !== null);
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
useEffect(() => {
@@ -85,6 +96,14 @@ export function WorkspaceRouteLayout() {
<WorkspaceSlugProvider slug={workspaceSlug}>
<WorkspacePresencePrefetch />
<Outlet />
{/* Reads the welcome-store transient signal parked by
* OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay
* (onboarding / accept-invite / new-workspace) is open so the modal
* doesn't portal-jump in front of an active pre-workspace flow.
* Once the overlay closes the hook re-evaluates and pops the
* Modal — unless the store signal has already been consumed, in
* which case the hook renders null. */}
{!overlayActive && <WelcomeAfterOnboarding />}
</WorkspaceSlugProvider>
);
}

View File

@@ -0,0 +1,16 @@
import { useParams, useSearchParams } from "react-router-dom";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export function AttachmentPreviewRoute() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const filename = searchParams.get("name") ?? undefined;
if (!id) return null;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,355 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { useEffect } from "react";
// Shared in-memory state that the mocked tab store reads / mutates. The test
// records every method call so we can assert openInNewTab does NOT activate
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
type MockRouter = {
state: { location: { pathname: string } };
navigate: ReturnType<typeof vi.fn>;
};
type MockTab = {
id: string;
path: string;
pinned: boolean;
router: MockRouter;
};
function makeMockRouter(pathname: string): MockRouter {
return {
state: { location: { pathname } },
navigate: vi.fn(),
};
}
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
] as MockTab[],
},
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
}));
vi.mock("@/stores/tab-store", () => {
const store = {
get activeWorkspaceSlug() {
return state.activeWorkspaceSlug;
},
get byWorkspace() {
return state.byWorkspace;
},
openTab: state.openTab,
setActiveTab: state.setActiveTab,
switchWorkspace: state.switchWorkspace,
};
const useTabStore = Object.assign(
(selector?: (s: typeof store) => unknown) =>
selector ? selector(store) : store,
{ getState: () => store },
);
const getActiveTab = () => {
const slug = state.activeWorkspaceSlug;
if (!slug) return null;
const group = state.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
};
const useActiveTabIdentity = () => ({
slug: state.activeWorkspaceSlug,
tabId: state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
});
const useActiveTabRouter = () => null;
const resolveRouteIcon = () => "File";
return {
useTabStore,
getActiveTab,
useActiveTabIdentity,
useActiveTabRouter,
resolveRouteIcon,
};
});
vi.mock("@/stores/window-overlay-store", () => ({
useWindowOverlayStore: Object.assign(
() => null,
{ getState: () => ({ overlay: null, open: vi.fn(), close: vi.fn() }) },
),
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
() => null,
{ getState: () => ({ logout: vi.fn() }) },
),
}));
vi.mock("@multica/core/paths", () => ({
isReservedSlug: (s: string) =>
["login", "workspaces", "invite", "onboarding", "invitations"].includes(s),
}));
// DesktopNavigationProvider reads window.desktopAPI.runtimeConfig synchronously.
beforeEach(() => {
state.openTab.mockReset();
state.setActiveTab.mockReset();
state.switchWorkspace.mockReset();
state.openTab.mockImplementation(() => "tNew");
state.activeWorkspaceSlug = "acme";
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
],
},
};
Object.defineProperty(window, "desktopAPI", {
configurable: true,
value: {
runtimeConfig: { ok: true, config: { appUrl: "https://app.example" } },
},
});
});
import {
DesktopNavigationProvider,
TabNavigationProvider,
} from "./navigation";
import { useNavigation } from "@multica/views/navigation";
function captureAdapter(onAdapter: (adapter: ReturnType<typeof useNavigation>) => void) {
function Probe() {
const nav = useNavigation();
useEffect(() => {
onAdapter(nav);
}, [nav]);
return null;
}
return Probe;
}
describe("DesktopNavigationProvider.openInNewTab", () => {
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
expect(adapter).not.toBeNull();
adapter!.openInNewTab!("/acme/agents", "Agents");
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("activates the new tab when opts.activate is true (foreground)", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.openInNewTab!("/acme/agents", "Agents", { activate: true });
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("delegates to switchWorkspace for a cross-workspace path", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.openInNewTab!("/butter/inbox");
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
});
});
describe("DesktopNavigationProvider.push with pinned active tab", () => {
function pinActive(pathname: string) {
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: makeMockRouter(pathname),
};
}
it("redirects push to a new foreground tab when pathname differs", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
});
it("allows in-tab navigation when only search/hash changes", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/issues?filter=open");
// Pathname unchanged → pinned interception declines and falls through to
// the router's own navigate — openTab / setActiveTab must not fire.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
});
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/butter/inbox");
// Cross-workspace push runs through tryRouteToOtherWorkspace before
// tryRouteToPinnedNewTab, so switchWorkspace wins.
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
expect(state.openTab).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.openInNewTab", () => {
function renderTabProvider() {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
const fakeRouter = {
state: { location: { pathname: "/acme/issues", search: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return () => adapter!;
}
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
const getAdapter = renderTabProvider();
getAdapter().openInNewTab!("/acme/agents", "Agents");
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("activates the new tab when opts.activate is true (foreground)", () => {
const getAdapter = renderTabProvider();
getAdapter().openInNewTab!("/acme/agents", "Agents", { activate: true });
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.push with pinned active tab", () => {
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
function renderPinnedTabProvider(pathname: string) {
// The active tab and the per-tab router must share the same pathname:
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
// pathname (so query-only pushes routed via React Router still compare
// correctly), while the TabNavigationProvider falls back to *its own*
// router.navigate when no interception fires. In real desktop usage they
// are the same router instance; this helper mirrors that invariant.
const fakeRouter = {
state: { location: { pathname, search: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as ProviderRouter;
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: fakeRouter as unknown as MockRouter,
};
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return { getAdapter: () => adapter!, fakeRouter };
}
it("redirects push to a new foreground tab when pathname differs", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
// Pinned interception short-circuits — the per-tab router must NOT
// navigate, otherwise the pinned tab itself would move off its path.
expect(fakeRouter.navigate).not.toHaveBeenCalled();
});
it("allows in-tab navigation when only search/hash changes", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/issues?filter=open");
// Same pathname → pinned interception declines, push falls through to
// the tab's own router.navigate, and no new tab is opened.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
});
});

View File

@@ -108,6 +108,37 @@ function tryRouteToOtherWorkspace(path: string): boolean {
return true;
}
/**
* Intercept pushes originating in a pinned tab and force them into a new
* tab. Returns `true` if the navigation was redirected (caller should NOT
* proceed). Pathname-only changes (search / hash / same-page state) are
* allowed through so pinned filter / drawer / form-state interactions
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
* D2b (FINAL: same pathname → allowed in pinned tab).
*
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
* if one exists, otherwise creates a new one. The newly-focused tab is
* activated foreground — a pinned-tab push is an explicit user action, not
* a background cmd+click, so the focus follows.
*/
function tryRouteToPinnedNewTab(path: string): boolean {
const store = useTabStore.getState();
const active = getActiveTab(store);
if (!active?.pinned) return false;
// Use the live router pathname rather than `active.path` so query-only
// navigations performed via React Router (which only sync pathname back
// to the store) still compare correctly.
const currentPathname = active.router.state.location.pathname;
const newPathname = path.split("?")[0].split("#")[0];
if (currentPathname === newPathname) return false;
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, path, icon);
if (newId) store.setActiveTab(newId);
return true;
}
/**
* Root-level navigation provider for components outside the per-tab
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
@@ -165,6 +196,7 @@ export function DesktopNavigationProvider({
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
active?.router.navigate(path);
},
replace: (path: string) => {
@@ -178,9 +210,16 @@ export function DesktopNavigationProvider({
},
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
openInNewTab: (
path: string,
title?: string,
opts?: { activate?: boolean },
) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there; same-workspace just adds a tab in the current group.
// the path there (focus follows the user); same-workspace defaults
// to background tab (browser cmd+click semantics). Callers that
// represent an explicit "Open in new tab" CTA pass `activate: true`
// to bring the new tab to the foreground.
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
@@ -188,8 +227,10 @@ export function DesktopNavigationProvider({
return;
}
const icon = resolveRouteIcon(path);
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
const newId = store.openTab(path, title ?? path, icon);
if (opts?.activate && newId) {
store.setActiveTab(newId);
}
},
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),
@@ -231,6 +272,7 @@ export function TabNavigationProvider({
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
router.navigate(path);
},
replace: (path: string) => {
@@ -241,7 +283,11 @@ export function TabNavigationProvider({
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
openInNewTab: (
path: string,
title?: string,
opts?: { activate?: boolean },
) => {
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
@@ -249,8 +295,10 @@ export function TabNavigationProvider({
return;
}
const icon = resolveRouteIcon(path);
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
const newId = store.openTab(path, title ?? path, icon);
if (opts?.activate && newId) {
store.setActiveTab(newId);
}
},
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),

View File

@@ -13,6 +13,7 @@ import { SkillDetailPage } from "./pages/skill-detail-page";
import { AgentDetailPage } from "./pages/agent-detail-page";
import { MemberDetailPage } from "./pages/member-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
@@ -24,12 +25,40 @@ import { AgentsPage } from "@multica/views/agents";
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { useT } from "@multica/views/i18n";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
* from i18n. The route element has to be a component (not a literal JSX
* value) for `useT` to run.
*/
function DesktopSettingsRoute() {
const { t } = useT("settings");
return (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: t(($) => $.desktop.tabs.updates),
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
);
}
/**
* Sets document.title from the deepest matched route's handle.title.
* The tab system observes document.title via MutationObserver.
@@ -160,6 +189,11 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Squad" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "attachments/:id/preview",
element: <AttachmentPreviewRoute />,
handle: { title: "Attachment" },
},
{
path: "usage",
element: <DashboardPage />,
@@ -167,24 +201,7 @@ export const appRoutes: RouteObject[] = [
},
{
path: "settings",
element: (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),
element: <DesktopSettingsRoute />,
handle: { title: "Settings" },
},
],

View File

@@ -17,6 +17,7 @@ vi.mock("../routes", () => ({
import {
sanitizeTabPath,
migrateV1ToV2,
migrateV2ToV3,
useTabStore,
} from "./tab-store";
@@ -277,3 +278,155 @@ describe("useTabStore actions", () => {
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});
describe("togglePin", () => {
it("flips a tab's pinned state", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
});
it("moves a newly-pinned tab to the start of the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs[0].id).toBe(agentsId);
expect(tabs[0].pinned).toBe(true);
expect(tabs[1].pinned).toBe(false);
expect(tabs[2].pinned).toBe(false);
});
it("appends a second pinned tab after the first pinned tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
store.togglePin(projectsId);
// Both pinned, in the order they were pinned (agents first, projects
// second), then the unpinned default tab.
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([
agentsId,
projectsId,
tabs[2].id,
]);
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
});
it("returns an unpinned tab to the start of the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
// Pin both, then unpin one.
store.togglePin(issuesId);
store.togglePin(projectsId);
store.togglePin(issuesId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
});
});
describe("moveTab boundary clamp", () => {
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag the pinned tab to index 2 (unpinned zone end).
store.moveTab(0, 2);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
expect(tabs[0].id).toBe(issuesId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag agents (index 2) to index 0 (pinned zone).
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// Clamped to index 1 — start of the unpinned zone.
expect(tabs[0].id).toBe(issuesId);
expect(tabs[1].id).toBe(agentsId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("reorders freely within the same zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
// All unpinned; move agents (2) to position 0.
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.path)).toEqual([
"/acme/agents",
"/acme/issues",
"/acme/projects",
]);
});
});
describe("migrateV2ToV3", () => {
it("adds pinned=false to every persisted tab", () => {
const v2 = {
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
],
},
},
};
const v3 = migrateV2ToV3(v2);
expect(v3.activeWorkspaceSlug).toBe("acme");
expect(v3.byWorkspace.acme.tabs).toEqual([
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
]);
});
it("handles missing byWorkspace gracefully", () => {
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
expect(v3.byWorkspace).toEqual({});
expect(v3.activeWorkspaceSlug).toBeNull();
});
});

View File

@@ -20,6 +20,14 @@ export interface Tab {
router: DataRouter;
historyIndex: number;
historyLength: number;
/**
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
* X close button, and turn any `navigation.push()` originating in them into
* an `openInNewTab()` so they stay parked on their original path. Pinning
* is invariant-preserving: pinned tabs always come before unpinned tabs in
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
*/
pinned: boolean;
}
export interface WorkspaceTabGroup {
@@ -78,8 +86,20 @@ interface TabStore {
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Patch history tracking of a tab. Finds across groups. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Reorder within the active workspace's group only. */
/**
* Reorder within the active workspace's group only. Clamped so a tab can
* never cross the pinned / unpinned boundary — a drag that would move a
* pinned tab into the unpinned zone (or vice versa) is dropped at the
* boundary instead. This keeps the "pinned tabs first" invariant without
* requiring callers to know about it.
*/
moveTab: (fromIndex: number, toIndex: number) => void;
/**
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
* zone; unpinning moves it to the start of the unpinned zone. Both
* preserve the "pinned tabs before unpinned tabs" invariant.
*/
togglePin: (tabId: string) => void;
/**
* After the workspace list arrives/changes (login, realtime delete), drop
* any tab group whose slug is no longer in `validSlugs`, and repoint
@@ -190,9 +210,17 @@ function makeTab(path: string, title: string, icon: string): Tab {
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
pinned: false,
};
}
/** Index of the first unpinned tab in a group (== pinned count). */
function pinnedBoundary(tabs: Tab[]): number {
let i = 0;
while (i < tabs.length && tabs[i].pinned) i++;
return i;
}
/** Default entry point for a workspace — its issues list. */
function defaultPathFor(slug: string): string {
return `/${slug}/issues`;
@@ -453,17 +481,63 @@ export const useTabStore = create<TabStore>()(
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
// Clamp the drop position to within the source tab's group (pinned vs
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
const boundary = pinnedBoundary(group.tabs);
const source = group.tabs[fromIndex];
let clampedTo: number;
if (source.pinned) {
// boundary is exclusive upper bound for pinned-zone indices.
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
} else {
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
}
if (clampedTo === fromIndex) return;
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
...group,
tabs: arrayMove(group.tabs, fromIndex, toIndex),
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
},
},
});
},
togglePin(tabId) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const nextTab: Tab = { ...current, pinned: !current.pinned };
// Remove from current position, then insert at the new zone boundary:
// pinning → end of pinned zone (just before first unpinned tab)
// unpinning → start of unpinned zone (right after last pinned tab)
const withoutCurrent = [
...group.tabs.slice(0, index),
...group.tabs.slice(index + 1),
];
const newBoundary = pinnedBoundary(withoutCurrent);
const insertAt = newBoundary;
const nextTabs = [
...withoutCurrent.slice(0, insertAt),
nextTab,
...withoutCurrent.slice(insertAt),
];
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
validateWorkspaceSlugs(validSlugs) {
const { activeWorkspaceSlug, byWorkspace } = get();
let changed = false;
@@ -497,17 +571,23 @@ export const useTabStore = create<TabStore>()(
}),
{
name: "multica_tabs",
version: 2,
version: 3,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
// are dropped — they have no workspace to belong to, and the new
// model's invariant is "every tab lives in a workspace group".
if (version < 2 && persistedState && typeof persistedState === "object") {
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
let state = persistedState;
if (version < 2 && state && typeof state === "object") {
state = migrateV1ToV2(state as Partial<V1Persisted>);
}
return persistedState as V2Persisted;
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
if (version < 3 && state && typeof state === "object") {
state = migrateV2ToV3(state as V2Persisted);
}
return state as V3Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
@@ -517,15 +597,19 @@ export const useTabStore = create<TabStore>()(
{
activeTabId: group.activeTabId,
tabs: group.tabs.map(
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
rest,
({
router: _router,
historyIndex: _hi,
historyLength: _hl,
...rest
}) => rest,
),
},
]),
),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<V2Persisted> | undefined;
const persisted = persistedState as Partial<V3Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
@@ -552,9 +636,14 @@ export const useTabStore = create<TabStore>()(
router: createTabRouter(clean),
historyIndex: 0,
historyLength: 1,
pinned: pTab.pinned === true,
});
}
if (tabs.length === 0) continue;
// Enforce the "pinned first" invariant on rehydration in case a
// user (or a buggy older write) persisted the pinned tabs out of
// order. Stable sort preserves intra-group order.
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
? pGroup.activeTabId
: tabs[0].id;
@@ -605,6 +694,38 @@ interface V2Persisted {
byWorkspace: Record<string, V2PersistedGroup>;
}
interface V3PersistedTab {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
}
interface V3PersistedGroup {
tabs: V3PersistedTab[];
activeTabId: string;
}
interface V3Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V3PersistedGroup>;
}
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
const byWorkspace: Record<string, V3PersistedGroup> = {};
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
byWorkspace[slug] = {
activeTabId: group.activeTabId,
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
};
}
return {
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
byWorkspace,
};
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];

View File

@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
import { Callout } from "fumadocs-ui/components/callout";
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
## What an agent can do
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.

View File

@@ -5,7 +5,7 @@ description: 智能体agent是 Multica 工作区里的一等公民成员
import { Callout } from "fumadocs-ui/components/callout";
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
## 智能体能做什么
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **[被分配 issue](/assigning-issues)** —— 作为 assignee分配后它会自动开工
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace member list --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
Unassign:

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace member list --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
取消分配:

View File

@@ -1,6 +1,6 @@
---
title: Autopilots
description: Let agents start work on a cron schedule or trigger once manually via the UI or CLI.
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -16,13 +16,13 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
- **Description / prompt** — the work description the agent receives each run
- **Execution mode** — see below
- **Triggers** — at least one `schedule` (cron + timezone)
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
## Pick an execution mode
An autopilot has two execution modes. **Start with "create issue" mode.**
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
## Run it on a schedule
@@ -50,15 +50,109 @@ multica autopilot trigger <autopilot-id>
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
## Trigger from a webhook
Autopilots can also fire on inbound HTTP webhooks. Add a **Webhook** trigger
on the autopilot detail page; Multica generates a unique URL of the shape:
```
https://<your-multica-host>/api/webhooks/autopilots/awt_…
```
POST any JSON to that URL — Multica records a run with `source = webhook`,
stores the body as the run's `trigger_payload`, and dispatches the agent
exactly the way a schedule trigger would.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
In **create issue mode**, the inbound payload is appended to the new issue's
description so the agent can read it inline. In **run-only mode**, the
payload is part of the run context the daemon hands the agent.
### Payload shape
You can send your own envelope:
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
…or any JSON object/array. Multica normalizes it into an internal envelope:
```json
{
"event": "<inferred>",
"eventPayload": <your body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
When you don't provide an `event` field, Multica infers it from common
headers and body fields (`X-GitHub-Event` + body `action`,
`X-Gitlab-Event`, `X-Event-Type`, body `event`/`type`/`action`). When
nothing matches, the event is `webhook.received`.
When configuring GitHub or similar sources, set the content type to
`application/json` — form-encoded webhook payloads are not accepted.
### URL is a bearer secret
The generated URL **is** the credential. Anyone with it can fire the
autopilot. Treat it like a token:
- **Don't paste it into public issue threads, screenshots, or chat history.**
- **Rotate it if it leaks** — click "Rotate URL" on the trigger row, or run
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`. The
old URL stops working immediately.
- For sources that require strong source authentication, wait for
per-trigger HMAC signature verification; this v1 URL is bearer-only.
- Workspace members who can view the autopilot can read its webhook URLs
for now — tighter per-role secret visibility is a follow-up.
### Status-code semantics
Multica returns `200 OK` with a `status` field for normal no-op outcomes so
your provider's webhook-retry machinery doesn't keep hammering the URL:
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
— a run was dispatched.
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
— the assignee's runtime is offline; recorded as a `skipped` run.
- `{"status":"ignored","reason":"trigger_disabled"}` — the trigger is disabled.
- `{"status":"ignored","reason":"autopilot_paused"}` — the autopilot is paused.
- `{"status":"ignored","reason":"autopilot_archived"}` — the autopilot is archived.
Non-2xx responses cover real failures:
- `400` — invalid JSON, scalar body, or empty body.
- `404` — unknown token (`{"error":"webhook not found"}`).
- `413` — payload exceeded 256 KiB.
- `429` — per-token rate limit exceeded (defaults to 60 req/min).
### Self-hosted: configure your public URL
When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
the trigger response includes an absolute `webhook_url` and the UI shows a
ready-to-copy URL. Without it, the UI composes the URL from the client's
API origin — which is fine for desktop and same-origin web, but not for
custom self-hosted reverse proxies. Multica deliberately does not derive
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
reverse proxy cannot trick the server into minting webhook URLs pointing at
an attacker-controlled host.
## View run history
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
- Trigger source (`schedule` / `manual`)
- Trigger source (`schedule` / `manual` / `webhook`)
- Start time, completion time
- Status (`issue_created` / `running` / `completed` / `failed`)
- Status (`issue_created` / `running` / `completed` / `failed` / `skipped`)
- The linked issue (create issue mode) or `task` (run-only mode)
- Failure reason (if failed)
- Failure reason (if failed or skipped)
## What happens when an autopilot fails
@@ -72,7 +166,11 @@ Why no auto-retry: autopilots are already periodic, so adding system-level retri
## What's not yet available
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
**API-kind triggers are not wired up.** The trigger schema reserves an `api`
kind, but no ingress route fires it; the UI shows a Deprecated badge for
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
signature verification, IP allowlists, and provider-specific event presets
are tracked as follow-ups; v1 URLs are bearer-only.
## Next

View File

@@ -1,6 +1,6 @@
---
title: Autopilots
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
description: 让智能体按 cron 定时自己开工,或在 webhook 到来时被触发——也可以通过 UI / CLI 手动触发一次。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -16,13 +16,13 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
- **执行模式** — 见下节
- **触发器** — 至少加一条 `schedule`cron + 时区)
- **触发器** — 至少加一条 `schedule`cron + 时区)或 `webhook`
## 选择执行模式
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
## 让它按时间跑
@@ -50,15 +50,105 @@ multica autopilot trigger <autopilot-id>
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
## 通过 Webhook 触发
Autopilot 也可以由入站 HTTP webhook 触发。在详情页添加一个 **Webhook**
触发器Multica 会生成一个唯一的 URL
```
https://<你的 Multica host>/api/webhooks/autopilots/awt_…
```
向这个 URL POST 任意 JSON——Multica 会记录一条 `source = webhook` 的
run把请求体保存为 run 的 `trigger_payload`,然后按和 schedule 触发器
完全一致的方式派发给智能体。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
在**先建 issue 模式**下,入站 payload 会附加在新 issue 的描述里供智能体
直接读到;**直跑模式**下payload 也会随 run 一并交给 daemon。
### Payload 形态
可以发自己的封装:
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
也可以直接发任意 JSON 对象 / 数组。Multica 会规范化为内部封装:
```json
{
"event": "<推断>",
"eventPayload": <你的 body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
不带 `event` 字段时Multica 会按以下顺序从常见 header 和 body 字段
推断:`X-GitHub-Event` + body `action``X-Gitlab-Event`、
`X-Event-Type`、body 里的 `event` / `type` / `action`。都不命中时事件
名退化为 `webhook.received`。
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
表单编码的 webhook payload 在 v1 里不接受。
### URL 即 bearer secret
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
- **不要贴到公开 issue 评论、截图、聊天记录里。**
- **泄漏后立即重新生成**——在触发器上点"重新生成 URL",或运行
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`。
旧 URL 立即失效。
- 对需要强来源认证的源,等 per-trigger HMAC 签名校验上线v1 URL 仅
bearer。
- 当前能查看 Autopilot 的工作区成员都能看到它的 webhook URL——更细的
权限可见性是后续工作。
### 状态码语义
正常的 no-op 路径都返回 `200 OK` 加 `status` 字段,避免外部 webhook 重试
机制反复打:
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
—— 已派发一次 run。
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
—— 受派智能体的 runtime 离线,记为 `skipped` run。
- `{"status":"ignored","reason":"trigger_disabled"}` —— 触发器已禁用。
- `{"status":"ignored","reason":"autopilot_paused"}` —— Autopilot 已暂停。
- `{"status":"ignored","reason":"autopilot_archived"}` —— Autopilot 已归档。
非 2xx 是真正的失败:
- `400` —— 无效 JSON、scalar body、空 body。
- `404` —— 未知 token`{"error":"webhook not found"}`)。
- `413` —— 请求体超过 256 KiB。
- `429` —— 单 token 速率限制(默认 60 次 / 分钟)。
### 自托管:配置公开 URL
服务端设置 `MULTICA_PUBLIC_URL`(例如 `https://multica.example.com`)后,
触发器响应里会带绝对的 `webhook_url`UI 直接显示可复制的 URL。没设
时 UI 会用客户端的 API origin 拼出 URL——desktop 和同源 web 没问题,
但自定义反向代理就不行了。Multica **故意不**从 `Host` /
`X-Forwarded-Host` header 推断公开主机,避免反代配置失误时被诱导生成
指向攻击者域名的 webhook URL。
## 看运行历史
每次触发都会产生一条**运行记录**run可以在 Autopilot 详情页的"历史"tab 看到:
- 触发源(`schedule` / `manual`
- 触发源(`schedule` / `manual` / `webhook`
- 开始时间、完成时间
- 状态(`issue_created` / `running` / `completed` / `failed`
- 状态(`issue_created` / `running` / `completed` / `failed` / `skipped`
- 关联的 issue先建 issue 模式)或 `task`(直跑模式)
- 失败原因(如果失败)
- 失败原因(失败或跳过时
## Autopilot 失败会怎样
@@ -72,7 +162,10 @@ multica autopilot trigger <autopilot-id>
## 暂不可用的能力
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
**API 类型触发器尚未接入。** 触发器 schema 里留了 `api` 类型但没有
入站路由会触发它UI 会给已有的此类记录打 Deprecated 标签,也不显示
copy / rotate 操作。Per-trigger HMAC 签名校验、IP allowlist、按提供方
的事件预设是后续工作v1 URL 仅 bearer。
## 下一步

View File

@@ -39,7 +39,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|---|---|
| `multica workspace list` | List every workspace you can access |
| `multica workspace get <slug>` | Show details for one workspace |
| `multica workspace members` | List members of the current workspace |
| `multica workspace member list` | List members of the current workspace |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
## Issues and projects

View File

@@ -39,7 +39,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|---|---|
| `multica workspace list` | 列出你有权访问的所有工作区 |
| `multica workspace get <slug>` | 查看一个工作区的详情 |
| `multica workspace members` | 列出当前工作区的成员 |
| `multica workspace member list` | 列出当前工作区的成员 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
## Issue 和 Project

View File

@@ -210,7 +210,7 @@ multica workspace get <workspace-id> --output json
### List Members
```bash
multica workspace members <workspace-id>
multica workspace member list <workspace-id>
```
### Update Workspace
@@ -267,7 +267,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace member list --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
### Update Issue

View File

@@ -72,7 +72,7 @@ multica daemon status
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
## 5. Create an agent
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
multica issue assign MUL-1 --to my-agent-name
```
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace member list --output json`.
**What happens next from the daemon**:

View File

@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
multica issue assign MUL-1 --to my-agent-name
```
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace member list --output json`。
**接下来守护进程会**

View File

@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
### Comments in code

View File

@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效
### 代码注释

View File

@@ -128,6 +128,25 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
## Rate limiting (optional Redis)
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
| Variable | Default | Description |
|---|---|---|
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
<Callout type="warning">
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
</Callout>
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
## Daemon tuning parameters
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
@@ -161,12 +180,12 @@ The [GitHub PR ↔ issue integration](/github-integration) needs two variables.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations install button URL |
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → GitHub install button URL |
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
**Behavior when either is unset:**
- `Connect GitHub` in Settings → Integrations is **disabled** and shows a "not configured" hint to admins.
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.

View File

@@ -128,6 +128,25 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
## 速率限制(可选 Redis
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**fail-open后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 连接 URL例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
<Callout type="warning">
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP整个部署落到同一个桶里`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
</Callout>
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
## 守护进程的调节参数
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
@@ -161,12 +180,12 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
**任一变量未设时:**
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。

View File

@@ -219,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |

View File

@@ -5,7 +5,7 @@ description: Connect a GitHub App once, then PRs whose branch, title, or body re
import { Callout } from "fumadocs-ui/components/callout";
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
Connect a GitHub account or organization once in **Settings → GitHub**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
There is no per-issue setup. The whole flow is identifier-driven.
@@ -13,7 +13,7 @@ There is no per-issue setup. The whole flow is identifier-driven.
| Surface | Behavior |
|---|---|
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
| **Settings → GitHub** | Workspace admins see the GitHub tab with a master toggle, **Connect GitHub** button, and feature switches (PR sidebar, Co-authored-by, auto-link). After install you bounce back to the GitHub tab. |
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
@@ -56,10 +56,10 @@ The action is attributed to the `system` actor on the timeline. Subscribers of t
## Disconnecting
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
In **Settings → GitHub** there is no installation list — you manage existing installations from GitHub directly:
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
- **Disconnect from inside Multica is admin-only** — the Disconnect control on the GitHub tab is hidden for non-admins. It stays available even when the master GitHub switch is off, so admins can still revoke a stale installation after one-click-disabling the feature.
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
@@ -121,7 +121,7 @@ Both variables are required. If either is missing:
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings` after install.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings?tab=github` after install.
Restart the API after setting the env vars.
@@ -139,10 +139,10 @@ Three tables get created: `github_installation`, `github_pull_request`, `issue_p
In Multica:
1. Open **Settings → Integrations** as an owner or admin.
1. Open **Settings → GitHub** as an owner or admin.
2. Click **Connect GitHub**. GitHub opens in a new tab.
3. Pick the repositories to grant access to and **Install**.
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`.
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.

View File

@@ -5,7 +5,7 @@ description: 一次性连接 GitHub App之后 PR 的分支名、标题或正
import { Callout } from "fumadocs-ui/components/callout";
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时issue 自动转 **Done**。
在 **Settings → GitHub** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时issue 自动转 **Done**。
没有 per-issue 的配置,整个流程是「编号驱动」的。
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
| 出现位置 | 行为 |
|---|---|
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
| **Settings → GitHub** | 工作区 owner / admin 看到 GitHub 这个 tab里面有主开关、**Connect GitHub** 按钮以及功能开关PR 侧栏、Co-authored-by、auto-link。点 Connect 会打开 GitHub 的 App 安装页;装好后跳回 GitHub tab。 |
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR含标题、仓库、状态`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
| **Webhook后台** | 每次 `pull_request` 事件触发upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
@@ -56,10 +56,10 @@ PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`issue
## 断开连接
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
**Settings → GitHub** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
- **Multica 这边的断开是 admin only** —— GitHub tab 上的 Disconnect 控件对非 admin 不显示;主开关关掉时 Disconnect 仍然可用,方便 admin 一键关闭功能后再单独清理已连接的 installation
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR但来自这个 installation 的新 webhook 事件不再被接受。
@@ -121,7 +121,7 @@ GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
- Settings 里 `Connect GitHub` 按钮会被 **disable**并显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
设完 env 重启 API。
@@ -139,10 +139,10 @@ make migrate-up
到 Multica
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
1. 以 owner 或 admin 身份打开 **Settings → GitHub**
2. 点 **Connect GitHub**GitHub 在新 tab 打开
3. 选择要授权的仓库,点 **Install**
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。

View File

@@ -0,0 +1,169 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
- [Daemon and runtimes](/daemon-runtimes) — how detection works
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
<Callout type="info">
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
</Callout>
## Before you start
Two prerequisites apply to **every** tool below:
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
After installing a tool, restart the daemon:
```bash
multica daemon restart
```
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 11 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
### Claude Code (Anthropic)
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
| | |
|---|---|
| Daemon looks for | `claude` |
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
| Notes | First-choice recommendation for new users. |
### Codex (OpenAI)
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
| | |
|---|---|
| Daemon looks for | `codex` |
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
### Cursor (Anysphere)
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
| | |
|---|---|
| Daemon looks for | `cursor-agent` |
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
### GitHub Copilot
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
| | |
|---|---|
| Daemon looks for | `copilot` |
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
| Authentication | Browser-based GitHub login through the CLI. |
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
### Gemini (Google)
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
| | |
|---|---|
| Daemon looks for | `gemini` |
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
### OpenCode (SST)
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
| | |
|---|---|
| Daemon looks for | `opencode` |
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
### Kiro CLI (Amazon)
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
| | |
|---|---|
| Daemon looks for | `kiro-cli` |
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
### Kimi (Moonshot)
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
| | |
|---|---|
| Daemon looks for | `kimi` |
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
| Authentication | Moonshot API key, configured per the vendor's docs. |
### Hermes (Nous Research)
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
| | |
|---|---|
| Daemon looks for | `hermes` |
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
| Authentication | Per the vendor's docs. |
### OpenClaw
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
| | |
|---|---|
| Daemon looks for | `openclaw` |
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
### Pi (Inflection AI)
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
| | |
|---|---|
| Daemon looks for | `pi` |
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
| Authentication | Per the vendor's docs. |
## After installing
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
## Troubleshooting
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
## Next
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks

View File

@@ -0,0 +1,169 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异会话续接、MCP、模型选择
<Callout type="info">
Multica 服务器从不接触你的 API key也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
</Callout>
## 开始前
下面每一款工具都有两个共同前提:
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
装完一款工具后,重启守护进程:
```bash
multica daemon restart
```
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 11 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
### Claude CodeAnthropic
集成最完整的一款。会话续接好用MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
| | |
|---|---|
| 守护进程扫描 | `claude` |
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
| 备注 | 新用户首选。 |
### CodexOpenAI
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
| | |
|---|---|
| 守护进程扫描 | `codex` |
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
### CursorAnysphere
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id你传过去的续接 id 永远无效。
| | |
|---|---|
| 守护进程扫描 | `cursor-agent` |
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
| 认证 | 在 Cursor 编辑器里登录CLI 复用同一份会话。 |
### GitHub Copilot
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型GitHub 决定你拿到哪个模型。
| | |
|---|---|
| 守护进程扫描 | `copilot` |
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
| 认证 | CLI 里走 GitHub 浏览器登录。 |
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
### GeminiGoogle
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
| | |
|---|---|
| 守护进程扫描 | `gemini` |
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
### OpenCodeSST
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
| | |
|---|---|
| 守护进程扫描 | `opencode` |
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
| 认证 | 按 OpenCode 的文档配你自己的模型供应商Anthropic、OpenAI 等)。 |
### Kiro CLIAmazon
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作skills 拷到 `.kiro/skills/`。
| | |
|---|---|
| 守护进程扫描 | `kiro-cli` |
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
### KimiMoonshot
ACP 协议 agent主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
| | |
|---|---|
| 守护进程扫描 | `kimi` |
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
| 认证 | Moonshot API key按厂商文档配置。 |
### HermesNous Research
ACP 协议 agent和 Kimi 共享传输层。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
| | |
|---|---|
| 守护进程扫描 | `hermes` |
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
| 认证 | 按厂商文档。 |
### OpenClaw
开源 CLI agent 编排器。**模型绑在 agent 层**`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
| | |
|---|---|
| 守护进程扫描 | `openclaw` |
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
### PiInflection AI
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
| | |
|---|---|
| 守护进程扫描 | `pi` |
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
| 认证 | 按厂商文档。 |
## 装完之后
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
## 排错
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
更宽的症状看[排错指南](/zh/troubleshooting)。
## 接下来
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务

View File

@@ -19,6 +19,7 @@
"squads",
"---How agents run---",
"daemon-runtimes",
"install-agent-runtime",
"tasks",
"providers",
"---Collaborating with agents---",
@@ -38,6 +39,7 @@
"cli",
"auth-tokens",
"desktop-app",
"mobile-app",
"---Developers---",
"developers"
]

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,10 @@ Once it's up:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
</Callout>
## 2. Important: keep production safety on
<Callout type="warning">
@@ -99,21 +103,53 @@ Open [http://localhost:3000](http://localhost:3000):
## 5. Point the CLI at your own server
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
```bash
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
```
### 5a. Same machine
If you're running everything on one local machine:
If the CLI and the server run on the same host, the defaults already work:
```bash
multica setup self-host
```
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
### 5b. Cross-machine: front with a reverse proxy
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
```bash
multica setup self-host \
--server-url https://<your-domain> \
--app-url https://<your-domain>
```
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
```nginx
multica.example.com {
# WebSocket route — must come before the catch-all
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# Everything else → frontend
reverse_proxy 127.0.0.1:3000
}
```
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
## 6. Create an agent + assign your first task

View File

@@ -44,6 +44,10 @@ make selfhost
- **前端**[http://localhost:3000](http://localhost:3000)
- **后端**[http://localhost:8080](http://localhost:8080)
<Callout type="info">
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
</Callout>
## 2. 重要:保持生产安全配置
<Callout type="warning">
@@ -98,21 +102,53 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
## 5. 连接命令行工具到你自己的 server
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
```bash
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
```
### 5a. 同一台机器
本地就是一台电脑跑整套的话
CLI 和 server 在同一台机器上时,默认参数就够用
```bash
multica setup self-host
```
默认连 `http://localhost:8080`backend+ `http://localhost:3000`frontend
会自动连 `http://localhost:8080`backend+ `http://localhost:3000`frontend,引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
### 5b. 跨机访问:用反向代理把服务挡在前面
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理Caddy / nginx / Cloudflare Tunnel由它终结 TLS再反代到 `127.0.0.1:8080`backend和 `127.0.0.1:3000`frontend。然后把 CLI 指到公开的 HTTPS 域名:
```bash
multica setup self-host \
--server-url https://<你的域名> \
--app-url https://<你的域名>
```
最小可用的 Caddyfile单域名同时挂前后端带 WebSocket 转发daemon 和网页端都依赖):
```nginx
multica.example.com {
# WebSocket 路由——必须在 catch-all 之前
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# 其它请求 → 前端
reverse_proxy 127.0.0.1:3000
}
```
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLShost 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
## 6. 创建智能体 + 分配第一个任务

View File

@@ -126,7 +126,7 @@ There is currently no unarchive command; create a new squad if you need the rout
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace member list --output json`, and `multica squad list --output json`.
## Next

View File

@@ -126,7 +126,7 @@ multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agen
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace member list --output json`、`multica squad list --output json` 拿。
## 下一步

View File

@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
Behavior:
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- By default, targets the issue's **current agent assignee** — useful when you want the rerun to follow the current assignment regardless of who ran the prior task.
- The execution-log retry button on a specific row sends that row's task ID alongside, so the rerun targets **the agent that ran that exact task** — not the current assignee. This makes per-row retry meaningful for squad workers, parallel @-mention agents, or rows whose agent has since been displaced by a reassignment.
- **Cancels** the target agent's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
@@ -89,7 +90,7 @@ Comparison:
| Trigger | System, based on failure reason | You, manually |
| Ceiling | 2 attempts | No limit |
| Applicable sources | Issues, chat | Issues with an agent assignee |
| Agent picked | Same agent as the failed task | Issue's current assignee |
| Agent picked | Same agent as the failed task | Source task's agent (UI per-row retry) or issue's current assignee (CLI / no task_id) |
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
## How a failed task affects issue status

View File

@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
行为:
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
- 创建一个**全新**的执行任务——尝试次数重置为 1即使原任务已达最大尝试。
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行再继续之前的对话只会重放被污染的上下文。自动重试则相反会继承会话——那条路径处理的是基础设施层面的失败不是产出不好。
@@ -89,7 +90,7 @@ multica issue rerun <issue-id>
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
| 上限 | 2 次 | 无上限 |
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry那一行任务的 agentCLI / 不带 task_idissue 当前的分配人 |
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
## 失败的任务对 issue 状态有什么影响

View File

@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
<Callout type="warning">
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."

View File

@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
- **Slug短链标识符** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符
<Callout type="warning">
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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