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>
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>
- 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>
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>
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>
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>
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>
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.
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>
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>
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>
* 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>
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>
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>
* 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>
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>
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>
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>
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>
* 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>
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>
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>
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>
* 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>
* 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>
* 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>
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>
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>
* 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>
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>
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>
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>
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>
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>
Internal navigation on web feels laggy because clicking a sidebar link blocks
0.2–0.6s with zero visual feedback — no prefetch, no Suspense fallback in the
dashboard segment, and no React transition to mark the route commit as pending.
This change adds the three pieces App Router needs to make the click→commit
window feel instant, scoped to the (dashboard) segment so auth/landing keep
their existing chrome:
- NavigationAdapter gains an optional prefetch(path). The web adapter wires
it to router.prefetch; desktop leaves it undefined (react-router has no
equivalent and doesn't need one). AppLink prefetches on hover/focus and
preserves caller-supplied onMouseEnter/onFocus/onClick.
- NavigationProvider wraps push/replace in useTransition and exposes the
pending flag via useIsNavigating(). Every useNavigation().push caller —
sidebar AppLink, command palette, post-create modal jumps — picks this up
automatically.
- New apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx renders a minimal
skeleton during cold transitions inside the dashboard segment only.
- DashboardLayout renders a 1px top progress bar driven by useIsNavigating.
packages/views remains free of next/* imports; desktop is unaffected by
construction (no prefetch, transition flips quickly, no loading.tsx).
Co-authored-by: multica-agent <github@multica.ai>
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>
CI lint failed on react/no-unescaped-entities. Two pre-existing JSX
literals contained raw apostrophes; replace with '.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>