* fix(issues): render thread replies in chronological order (#3691)
collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).
All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): rebuild skills list on shared Linear-style list grid
- new ListGrid primitives (subgrid: single source of truth for column tracks)
- skills list: sortable columns, used-by avatar stack, source/creator columns,
row kebab + batch toolbar with add-to-agent and delete
- skill view store in core; addAgentSkills client method; HoverCheck extracted
to views/common (issues header now imports the shared copy)
- locale keys for list actions/filters and the reworked detail page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): rework detail page into overview/files tabs
- tabs directly under the breadcrumb header: overview (default) and files
- overview: identity block + rendered SKILL.md as the main column, right
rail with metadata card (source/creator/updated, inline name+description
edit toggle) and used-by panel with bind/unbind
- files: file tree + viewer/editor unchanged; SKILL.md "edit" jumps here
- header kebab menu (copy skill ID, delete); page-level save bar shared by
both tabs; tab state persisted in ?tab=
- file tree: ARIA tree roles + roving-tabindex keyboard navigation
- drop the old right sidebar (metadata dl, permissions paragraph)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* revert(skills): restore detail page to main, keep branch list-only
Drop the overview/files tabs rework from this branch so the PR scope is
the list rebuild only. skill-detail-page.tsx and file-tree.tsx are back
to the main versions; the locale detail/file_tree sections are restored
to match. The detail rework is preserved on stash/skills-detail-tabs
for a follow-up PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): drop description column from skills list
Description is agent-facing routing metadata, not a scannable list
property — Linear's display options expose no description column for
the same reason. Removes the cell, column key, display toggle, lg grid
track, skeleton cells, and the now-dead table.description /
table.no_description locale keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): drive list column hiding by container width, drop by priority
Replace viewport sm:/lg: breakpoints with Tailwind v4 container query
variants (@2xl/@4xl) on the list wrapper, so an open sidebar or split
pane narrows the column set instead of squashing tracks. Remove the
min-w-fit + overflow-x-auto horizontal-scroll fallback: when space runs
out, low-priority columns (created/source/creator, then updated) drop
and return as the container widens; name and usedBy never drop. ListGrid
conventions comment updated — this is the template for all list pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): virtualize list rows with @tanstack/react-virtual
Linear-style headless virtualization: the virtualizer computes the
visible index range and offsets; offsets land as padding on the
scrolling ListGridBody so mounted rows stay direct subgrid children and
column alignment is untouched. Fixed 48px rows skip per-row measurement.
Hideable column tracks move from max-content to deterministic widths
(CSS vars) — with only the visible slice mounted, content-driven tracks
would resize during scroll. A user-hidden column zeroes its var so the
track still collapses; per-cell max-w caps move into the tracks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): list tiers must fit their container trigger width
The @4xl tier's track sum (~1080px with gaps) exceeded its 896px
trigger; with the horizontal-scroll fallback gone, the right-side
columns were clipped unreachably between 896-1080px. Move tier 3 to
@5xl (1024px), trim usedBy/source/creator tracks, and document the
fit invariant with its arithmetic next to the template and in the
ListGrid conventions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): show description as subtext under the skill name
Lives in the name track as a second truncated line (max-w 36rem,
title attr for the full text) — no track, no header, no slot in the
responsive arithmetic. Both lines fit the fixed 48px row, so the
virtualizer contract is untouched; rows without a description center
the name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Revert "feat(skills): show description as subtext under the skill name"
This reverts commit f39721301b.
* fix(skills): anchor batch toolbar to the page, not the viewport
fixed bottom-6 left-1/2 centered the bar on the window; with the
sidebar open the list's visual center sits ~120px right of the window
center, so the bar looked off-center (worse with desktop split panes).
Page root becomes the positioning context (relative) and the bar uses
absolute — same rule applies to future list pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): show matching count next to search while list is narrowed
"n / total" appears right of the search box only when search or
filters are active — idle state would duplicate the total already in
the page header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): derive trigger kinds, next run, last run status in list
The list endpoint only selected the autopilot table, so the list UI
could not answer "is this automation working" without N+1 detail
calls. Each list row now carries trigger_kinds + next_run_at (enabled
triggers only — the columns describe how it fires today) and
last_run_status (most recent run). Fields are omitempty and absent
from detail/create/update responses; clients must treat them as
optional per the API compatibility rules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): list schema, parsed client, and view store in core
- listAutopilots now runs through parseWithFallback with a zod schema
(this endpoint was a bare fetch — overdue per the API compatibility
rules); malformed bodies degrade to an empty list, old-server rows
without assignee_type or the new derived fields parse cleanly, and
enum drift passes through as plain strings
- Autopilot type gains the three optional list-only derived fields
- New autopilots view store (scope/sort/columns/filters, persisted per
workspace): status is the promoted scope dimension so it does NOT
appear in filters — one dimension lives in exactly one place
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): rebuild list on shared ListGrid with scope buttons
Same skeleton as the skills list (container-query tiers, deterministic
var-width tracks with documented fit arithmetic, virtualized 48px rows,
sortable headers, filter + display toolbar, page-anchored batch
toolbar), plus the autopilots-specific pieces:
- Status is the promoted SCOPE dimension: 全部/运行中/已暂停/已归档
segmented buttons with full-set counts; "all" = active+paused
(archived gets its own visible home, Linear archive semantics);
status is therefore absent from the filter dropdown
- Columns: name (paused marker inline), assignee (agent/squad),
trigger kind badges, last run (outcome dot + time, enum-drift safe
default), next run; mode/creator/created opt-in hidden
- Filters: assignee, trigger kind, mode, creator (composite type:id
values for polymorphic actors); sort name/lastRun/nextRun/created
with lastRun desc default
- Row kebab (pause/resume/archive/unarchive/delete) and batch toolbar
share one delete dialog; status changes ride useUpdateAutopilot's
optimistic cache
- Fix noUncheckedIndexedAccess errors the branch had never typechecked
(skills virtual rows, UsedByCell, added_toast)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(autopilots): scope buttons follow the issues header pattern
Replace the bespoke segmented-pill control with the existing scope
button convention from the issues page: outline buttons with bg-accent
active state on md+, collapsing to a radio dropdown below md. Counts
stay (stage inventories from the full set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): toolbar small-screen treatment follows issues header
Below md: the search box (and its result count) disappear entirely,
and the filter/display controls collapse to square icon-only buttons
(labels and the clear-X are md+), matching the issues header's
responsive pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): two-zone columns — WYSIWYG with scroll escape valve
Static width tiers silently hid user-enabled columns (toggle on,
nothing appears — autopilots' mode/creator/created sat behind a 1280px
container gate no laptop reaches; skills' source/created behind
1024px). Tiers can't know how many columns are enabled, so the
mechanism is replaced, not retuned:
- ≥@2xl container: every enabled column renders; the grid carries
min-width = Σ(enabled tracks + gaps) (pure constants, no
measurement) and the wrapper scrolls horizontally only when the
enabled set outgrows the container
- <@2xl: static core set (skills: name+usedBy; autopilots:
name+assignee), no scroll, toggles don't apply
Per-tier templates and the hand-maintained fit arithmetic retire;
ListGrid conventions updated accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): widen name column minimums (120px base, 200px wide)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(autopilots): drop the archived scope and the list search box
Archiving never existed as a UI flow (the DB status value is only
reachable via direct API; the detail page disables its switch when
archived), so the list stops inventing it: no archived scope, no
archive/unarchive row or batch actions. API-archived rows are excluded
everywhere; a persisted retired scope value falls back to "all".
The search box goes too — scope buttons already partition the small
set, search is redundant (product call). Skills keeps its search (no
scope there).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills,autopilots): quiet outline create buttons in page headers
Page-header chrome shouldn't carry the loudest element on the page:
the create button becomes outline with text on md+ and collapses to a
square plus icon below md (same responsive treatment as the toolbar
controls). Primary stays reserved for empty-state CTAs. Agents follows
when its list migrates to ListGrid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents): rebuild list on shared ListGrid with identity rows
Same skeleton as the skills/autopilots lists (two-zone container
responsiveness, deterministic var tracks + min-width scroll escape
valve, virtualized fixed-height rows, issues-style scope buttons,
page-anchored batch toolbar, quiet outline create button), plus the
agents-specific decisions:
- Identity rows: the documented exception to the single-line rule —
avatar + name + description two-line cells, 64px rows (agents are
few, identity-rich entities); the italic "no description"
placeholder is gone, empty descriptions just center the name
- Scope: Mine (historical default) | All | Archived with full-set
counts; archived ignores the ownership lens; no search box
- The 7d sparkline column is replaced by a sortable "Last active"
column derived from the same 30-day activity buckets (zero API
change) — per-row-normalized mini bars can't be compared across
rows, and the default sort finally has a visible anchor; the
detailed histogram stays on the hover card / detail page
- Workload folds into the status cell ("Online · 2 tasks") — a 0-2
integer doesn't earn a column
- Columns: status, runtime, last active, runs (30d); model/created
opt-in hidden; filters: availability, runtime
- Operations unchanged: row kebab reuses AgentRowActions
(cancel-tasks/duplicate/archive/restore with permissions); batch
archive (confirmed) + restore; no delete — the API has none
- View store extended (scope incl. archived, sort, columns, filters);
agent-columns.tsx (DataTable columns) deleted
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): trim status track to its real worst case (160 -> 144px)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(runtimes): machine detail's runtime table on the shared ListGrid
The master-detail console keeps its shape (machines are few and
strongly categorized; left list, charts, update section untouched) —
only the right pane's runtimes table moves from TanStack DataTable to
the ListGrid family, taking the paradigm pieces that earn their keep
at 1-5 rows: subgrid template + var tracks, two-zone container
responsiveness (the pane is squeezed by the machine list, so the
core-set collapse below @2xl matters more here than on full-width
pages), min-width scroll escape valve, shared header/row/hover visual
language. Deliberately NOT taken: virtualization, sorting, filters,
column toggles, and batch selection — dead weight at this row count,
and batch-deleting runtimes (a cascade-confirm operation) is unsafe
by design.
Workload folds into the health cell ("Online · Working 2") like the
agents status cell; the owner column keeps its only-when-multiple-
owners rule via a zeroed track var. runtime-columns.tsx is deleted;
the row-menu/CLI tests render the exported cells directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(runtimes): collapse the kebab track when no row has actions
On a healthy local machine every row's only action (delete) is hidden
by the self-healing rule, leaving a permanent ~64px dead zone after
the CLI column. The action track now follows the owner column's
conditional-var mechanism: zeroed unless at least one row will show
the menu.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(runtimes): drop doubled header border, align create button with convention
PageHeader already carries border-b; the content wrappers' border-t
stacked a second line right under it (the only list page doing this).
"Add a computer" follows the chrome-button convention: outline with
text on md+, square plus icon below md — primary stays reserved for
the empty state CTA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(runtimes): health cell load suffix matches the agents status cell
"Healthy · 2 tasks" instead of the old workload vocabulary
("Working 2 +1q") — the count is unit-bearing and both surfaces now
speak one language. The queued-anomaly distinction the old words
hinted at belongs to the health layer if it ever earns surfacing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(lists): pin overflow-y-hidden on the horizontal-scroll wrappers
CSS coerces overflow-x:auto into overflow:auto on both axes, which
silently armed the list wrappers with a vertical scrollbar they were
never meant to have. Combined with the h-full grid's percentage
resolution across scrollbar-induced reflows, the wrapper's vertical
bar and horizontal bar fed each other in a non-converging layout loop
(visible as two stacked, flickering scrollbars on the agents list —
the same latent loop exists in all four wrappers; agents' wider
min-width and 64px rows just hit the trigger zone first). Vertical
scrolling belongs solely to ListGridBody; declare overflow-y-hidden
explicitly to break the loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): single scroll container for the list (trial before rollout)
Both scroll axes move to the outer wrapper; the grid drops h-full and
the rows wrapper drops its own overflow. Kills the percentage-height
bridge between the two scroll elements that fed the flickering double
scrollbars and clipped the last row under the horizontal scrollbar.
Sticky header pins inside the scroller; vertical scrollbar now spans
the full pane (Linear's structure). Skills/autopilots follow after
visual confirmation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(lists): roll single scroll container out to skills/autopilots, add bottom clearance
ListGridBody retires its own scrolling entirely (the agents trial
confirmed the structure): both axes live on the single outer wrapper,
grids drop the h-full percentage bridge, virtualizers point at the
wrapper. The rows wrapper gains LIST_GRID_BOTTOM_CLEARANCE (64px)
appended to the virtualization padding so the last row scrolls clear
of the chat FAB (~48px at bottom-right) and the batch toolbar (~62px).
Runtimes' machine table is untouched: content-height at the top of a
tall pane, no bridge and no practical FAB overlap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(squads): rebuild list on shared ListGrid (identity rows, minimal)
The last list joins the family. Squads are the fewest entity (1-5 rows),
so this is the agents identity-row shell on the runtime-list minimal
skeleton: ListGrid subgrid + var tracks + two-zone responsiveness +
single scroll container, but NO virtualization, checkbox, or batch.
- Identity two-line rows (squad avatar + name + description, 64px) like
agents; columns: name / leader / members (polymorphic ActorAvatar
stack from member_preview), creator + created opt-in hidden
- Scope Mine/All (creator-based, issues-header styling, <md dropdown);
no archived scope (list API hard-filters archived + no restore
endpoint), no search (scope-bearing), no filters (set too small)
- Sort name (default) / members / created
- Row kebab = Archive (= the delete endpoint, which archives + transfers
issues/autopilots to the leader); workspace owner/admin only, so the
kebab track collapses for non-admins. Reuses the existing
archive_dialog copy. No batch.
- View store extended (scope + sort + columns); zero API change — pure
frontend (member_preview/count already in the list payload)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents,squads): owner/created-by columns + owner filter
Surface ownership as a real column on both lists, named by what the
field actually means in each permission model:
- Agents: "Owner" — owner_id is the creator (set at creation, never
transferred) and carries management rights. Promoted to a default-
visible column (avatar + name); the half-baked inline owner avatar in
the name cell is removed ("You" badge stays).
- Squads: "Created by" (NOT Owner) — creator_id holds no rights
(archiving is workspace-admin only), so Owner would mislead. Now a
default-visible column with avatar + name.
Agents also gains an Owner filter, kept orthogonal to the Mine scope by
the single-axis rule: "Mine" is the clean no-filter personal view, so
applying any filter (owner or otherwise) leaves Mine for All, and
clicking Mine clears all filters. Owner and Mine therefore never
coexist — no "mine + owner=someone-else = empty" contradiction. Squads
keep the plain Mine/All toggle (too few rows for a creator filter).
Both lists keep a Created (date) column, opt-in hidden.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): backfill new filter dimensions on rehydrate (owners crash)
A view payload persisted before the owners filter existed overwrote the
default filters wholesale on rehydrate, dropping filters.owners to
undefined and crashing the list's filter predicate (.length on
undefined). The store merge now deep-merges filters over
EMPTY_AGENT_FILTERS so newly-added dimensions always get their default.
Regression test added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills,autopilots): deep-merge filters on rehydrate too
Same latent crash the agents store just hit: the copied view-store
merge spread persisted.filters wholesale, so adding a new filter
dimension later would drop it to undefined for users with older
persisted state. Harden skills and autopilots the same way (merge over
their EMPTY_*_FILTERS) before that bug can ship.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(projects): rebuild table view on ListGrid + filters + pin/delete kebab
Projects is the dual-view list: the compact table moves onto the shared
ListGrid (subgrid tracks, two-zone responsiveness, single scroll
container, FAB bottom clearance) while the comfortable card grid stays
as the alternate view, toggled by a restyled view switch (Table/Cards
outline buttons, active = bg-accent). Inline editing is preserved —
rows are NOT whole-row links; the name navigates and status/priority/
lead stay click-to-edit (matching prior behaviour, no navigate-vs-edit
conflict).
- View store extended: viewMode + sort (name/priority/status/progress/
created) + hidden columns + filters (status/priority/lead); merge
deep-merges filters (migration-safe). No scope (lead optional/often
an agent; status is a 5-value lifecycle → filter, not scope).
- Toolbar: search (kept — scopeless list) + result count + Filter
(status/priority/lead) + Display (sort+columns, table view only).
- Row kebab: Pin/Unpin (any member, reuses the existing project pin
API — zero new endpoints) + Delete (workspace admin). Pin is the
flexible per-user favourite the list previously lacked.
- Zero API change; status/priority filtering is client-side like the
other lists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(projects): GRID_COLS must be a literal string (Tailwind can't see interpolation)
The table view's grid-cols template interpolated ${STATUS_WIDTH}px, so
Tailwind never generated the arbitrary-value class — the grid collapsed
to one column and every cell stacked vertically. Inline the literal
116px. This is the documented ListGrid rule (keep the class literal so
Tailwind scans it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(projects): single view-toggle button, decouple Display from view mode
Two fixes from the same principle — view mode is pure presentation and
must not couple to anything:
- The view switch is now ONE button that flips table ⇄ cards (shows the
current view's icon+label, tooltip names the target), instead of two
side-by-side buttons.
- The Display (sort/columns) control no longer disappears when you
switch to cards — it was gated on isCompact, so flipping the view
made it vanish (the "filter gone after switching" weirdness). It's
always present now; only the columns *section* inside the popover is
table-only (cards have no columns). Sort applies to both views.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(projects,squads): projects multi-select + squads FAB clearance/toast
Cross-list consistency audit fixes:
- projects: add multi-select (checkbox column + select-all header +
page-anchored batch toolbar) — it's a dozens-scale full-page list
like skills/autopilots/agents but was the only one missing it. Batch
ops: Pin all (any member) + Delete (workspace admin). Table view
only (cards have no checkboxes). GRID template + min-width updated
for the checkbox track.
- squads: add the FAB bottom clearance the other full-page lists have
(last row/kebab was sliding under the chat FAB).
- squads: archive success toast was showing the dialog's question
title ("Archive this squad?"); use a proper "Squad archived" key.
Intentional and left as-is (documented): squads/runtimes have no
multi-select/virtualization (1-5 rows); projects table isn't
virtualized yet (dual-view + card grid; tracked as low-risk debt).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents,squads): close the filter/column consistency gaps
Apply the principle "every categorical column is filterable" where it
was missing:
- agents: add a Model filter (model was a categorical column with no
filter). Distinct non-empty models from the in-scope rows.
- squads: add filters entirely (it had leader/creator columns + a
column-toggle panel but no Filter button — the only such outlier).
Leader (agent) + Creator (member) filters, with the result count and
the same Filter dropdown shape as the other lists. Store gains
SquadListFilters + toggleFilter/clearFilters + migration-safe
filters deep-merge.
autopilots creator stays default-hidden per product call (not every
"who made it" must be visible). Filter stores' partialize tests
updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(autopilots): match list-page root to flex-1 convention
skills/agents/projects roots use `relative flex flex-1 min-h-0 flex-col`;
autopilots used `h-full`. Both anchor the batch toolbar correctly, but
align the flex sizing for consistency across the six list surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The two prior MUL-3254 fixes preserved draft/description state across a
modal close, but Desktop still could not RENDER the reopened image: in
CloudFront signed-URL mode every URL the renderer holds after reopen is
unloadable. The persisted record strips the expired signed download_url,
the raw CDN url is unsigned (403 on a signed distribution), and the
durable /api/attachments/<id>/download endpoint needs credentials that a
cross-site file:// <img> fetch cannot carry (web works via the same-site
session cookie, which is why the bug was desktop-only).
Two changes close the last mile:
- /api/config now reports cdn_signed when CloudFront signing is enabled,
and pickInlineMediaURL stops picking the raw (unsigned) CDN url in
that mode — it is a guaranteed 403.
- The Attachment renderer upgrades an auth-gated media URL to a freshly
signed one via authenticated GET /api/attachments/<id> (the same
re-sign the click-time download path already does), but only on
clients without a same-origin /api proxy (api.getBaseUrl() non-empty:
Desktop, mobile webview). Cached via TanStack Query with a 20-minute
staleTime, inside the server's 30-minute signed-URL TTL.
Old servers omit cdn_signed; the schema defaults it to false so behavior
is unchanged there. Non-CloudFront deployments return the API path again
from the metadata fetch and the renderer keeps the original URL.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* Add comment trigger preview suppression
Co-authored-by: multica-agent <github@multica.ai>
* Use TanStack Query for trigger preview
Co-authored-by: multica-agent <github@multica.ai>
* Test note comments skip create triggers
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): redesign comment trigger chips as avatar chips
Single agent renders as avatar + presence dot + full sentence; several
agents collapse to an overlapping stack + active count, mirroring the
header working chip. Per-agent skip moves into a click-opened popover
(hover layers stay read-only tooltips); suppression reads as brightness,
not a ban glyph. Loading and preview errors render nothing.
Also: share one tooltip body across chip and popover rows, invalidate
cached previews after a comment lands (the enqueued task changes the
dedup answer), move the preview query key into issueKeys, and drop the
now-unconsumed status field from useCommentTriggerPreview.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(server): drop comment trigger wrappers kept only for tests
enqueueMentionedAgentTasks and shouldEnqueueSquadLeaderOnComment had no
production callers after the compute/enqueue split — the comment path
goes through computeCommentAgentTriggers. Tests now exercise the compute
functions directly via package-local helpers, so the legacy adapters
cannot drift from the real path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(skills): sync mentioning/squads source maps with shared trigger computation
The squads source map still pointed the comment-trigger contract at the
pre-refactor call chain (comment.go:940 -> shouldEnqueueSquadLeaderOnComment),
and the mentioning skill referenced the deleted wrapper. Re-anchor both
to computeCommentAgentTriggers / computeAssignedSquadLeaderCommentTrigger
/ computeMentionedAgentCommentTriggers with current line numbers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.
Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.
Server:
- AttachmentResponse gains a markdown_url field, computed by
buildMarkdownURL from the deployment policy:
• storage URL is already absolute + unsigned (public CDN, S3 public
bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
use it verbatim;
• CloudFront-signed mode → never expose the raw S3 URL (private
bucket); return cfg.PublicURL + /api/attachments/<id>/download so
the server can re-sign on every request;
• LocalStorage relative + cfg.PublicURL set → same prefixed API
endpoint;
• cfg.PublicURL unset → fall back to site-relative path so web's
Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
signature query params, so a freshly-signed download_url can never
leak into persistence — the original MUL-3130 bug stays closed.
Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
carry markdown_url. Schema lenient-defaults to '' so a backend old
enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
(1) att.markdown_url (modern server),
(2) attachmentDownloadPath(att.id) — legacy site-relative shape,
retained for backends old enough to omit markdown_url,
(3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
reframed as the legacy-compat fallback for already-persisted
/api/attachments/<id>/download or /uploads/<key> URLs in old
bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
AttachmentDownloadProvider so Quick Create's editor can swap the URL
via the resolver during the same session even before the server-side
binding lands.
Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
tests (public CDN passthrough, CloudFront-signed swap, relative
prefixing, PublicURL unset fallback, trailing-slash strip) + 15
table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.
Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.
Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.
Refs: MUL-3192
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)
The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.
Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.
Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
and onto runPolling's initial region local. SwitchedDomain still
flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
with empty defaulting to feishu for back-compat.
Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
a single dialogRegion useState so an "open but with no region picked"
intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
region-aware copy (title, description, scan hint, link fallback,
success toast).
i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
install_scan_hint_*, install_open_link_fallback_*, and
install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
single-region keys are kept for now; nothing in the tree references
them anymore but a follow-up cleanup can remove them once the dust
settles.
Tests
- Two new lark.RegistrationClient tests pin region routing in both
directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
beginLarkInstall with the matching region argument. Existing CTA
tests updated to expect both buttons in place of one.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu
Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.
Backend (must-fix from review)
The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.
- PollResult now carries SwitchedDomain AND SwitchedRegion in
lockstep, so the caller never has to re-derive region from the
domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
host symmetrically with the existing tenant_brand=lark check, gated
on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
hardcoded RegionLark — the SwitchedDomain branch now flips both
feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
(table-driven, both directions) to pin that the gate doesn't loop.
Backend (nit from review)
Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).
Frontend (nit from review)
The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:
- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}
across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.
Desktop (separate report)
Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.
context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
Follow-up to #3797. The inbox:new handler keys the notification-preference
query on item.workspace_id, but the request itself still resolved its
workspace from the active-workspace X-Workspace-Slug header. On a cold
cache, a user viewing workspace B who received a workspace-A notification
read B's mute setting and cached it under A's key — so A's banners could
fire while muted (and vice-versa), polluting A's cache.
Add an optional workspaceSlug override to getNotificationPreferences and
notificationPreferenceOptions, and pass the resolved source slug from the
inbox:new handler. When the source slug can't be resolved, read only an
already-warm cache instead of fetching with the wrong workspace. Tests
cover the cold-cache source-slug fetch, source mute suppression, and the
no-fallback guard.
MUL-3062
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(db): add Lark integration migration (MUL-2671)
Introduces seven tables for the 飞书 Bot integration MVP — per-agent
PersonalAgent installations, user/chat bindings, inbound dedup +
non-content drop audit, outbound card mapping, and short-lived
single-use member binding tokens.
Schema notes:
- chat_session schema unchanged; Lark routes through a separate
binding table rather than adding a metadata JSONB column.
- Outbound card mapping is task/message scoped so multiple runs on
the same session can't stomp each other's cards.
- lark_inbound_audit stores routing / identity / drop_reason ONLY,
never message body — the audit channel for unbound users and group
messages that don't address the Bot.
- app_secret stores ciphertext (encryption helper lands in a follow-up
commit on this branch); DB never sees plaintext.
Co-authored-by: multica-agent <github@multica.ai>
* feat(util): add secretbox AES-256-GCM helper for at-rest secrets
First consumer is lark_installation.app_secret (MUL-2671 §4.4), but
the helper is intentionally generic — future per-tenant secrets that
must not appear in a DB dump can reuse it.
Construction: AES-256-GCM with a per-message random nonce, providing
authenticated encryption. Tampered ciphertext fails Open instead of
silently decrypting to garbage. Master key loaded from a base64 env
var via LoadKey; key rotation is not in scope yet.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): extract IssueService.Create as single create entry (MUL-2671)
Establishes the service-layer boundary mandated by Elon's 二审 of
MUL-2671 §4.8: issue creation no longer lives inside the HTTP
handler. Both the HTTP POST /issues handler and the future Lark
/issue command call into service.IssueService.Create, so duplicate
guard, issue numbering, attachment linking, broadcast, analytics,
and agent/squad enqueue stay aligned.
Handler responsibilities shrink to parsing the HTTP request, doing
actor resolution / validation (transport-specific), and converting
service results into the IssueResponse + 201. The transaction-wrapped
core, attachment link, event publish, analytics capture, and
agent/squad enqueue all move into service.IssueService.Create.
A BroadcastPayload callback on the service keeps the WS broadcast
shape (the full IssueResponse) without forcing the service to
depend on handler-layer response types.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations): add Lark package skeleton (MUL-2671)
Establishes the architectural boundaries Elon's 二审 mandated as
first-PR blockers without dragging in OAuth, WebSocket, or
card-patching code (those land in follow-up PRs):
- ChatSessionService interface — channel-aware chat-session entry
point for Lark, deliberately separate from the HTTP SendChatMessage
handler. The HTTP handler's single-creator guard (creator_id ==
request user_id) is correct for the browser client but rejects
group chat_sessions by construction; Lark needs its own service.
- AuditLogger interface — the only path for recording dropped events.
Its signature deliberately omits message body, enforcing the
drop-audit policy (MUL-2671 §4.7) at the type level: unbound users
and non-addressed group messages can't accidentally end up in
chat_session.
- Typed IDs (OpenID, ChatID) prevent UUIDs from being conflated with
Lark-side identifiers at compile time.
- DropReason constants align dashboard/audit queries across callers.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): move parent/project workspace check into IssueService (MUL-2671)
Parent existence and project workspace membership now live inside
IssueService.Create, inside the same transaction as the duplicate guard
and counter increment. The HTTP handler stops re-implementing the
lookup; every future create entry (Lark /issue, MCP, API keys) inherits
the same boundary without copy-pasting the SQL.
Adds two error sentinels (ErrParentIssueNotFound, ErrProjectNotFound)
so transports can translate to their own error shapes. Handler-level
cross-workspace tests guard the boundary against future regressions.
Co-authored-by: multica-agent <github@multica.ai>
* fix(db): harden Lark migration safety底座 — TTL cap + workspace FK (MUL-2671)
Two storage-layer hardenings that move the must-fix line off "the app
layer enforces it" and onto the schema itself, so future write paths or
hand-inserted rows cannot regress the invariants.
1) lark_binding_token TTL cap. The DB CHECK was 1 hour as
defense-in-depth while the app constant was 15 minutes; the CHECK
now matches the product cap (15 minutes). Application constant
docstring updated to reflect that storage enforces the same bound.
2) lark_user_binding workspace membership. The table previously only
FK'd to workspace / user / installation independently, so a binding
could exist for a user no longer in the workspace, or claim a
workspace different from its installation's. Two composite FKs
close the gap structurally:
* (installation_id, workspace_id) → lark_installation(id, workspace_id)
— guarantees a binding's workspace_id always matches its
installation's workspace_id. A new UNIQUE (id, workspace_id) on
lark_installation is added as the FK target.
* (workspace_id, multica_user_id) → member(workspace_id, user_id)
with ON DELETE CASCADE — when a user is removed from the
workspace, the binding cascades away in the same transaction.
There is no longer a path where lark_user_binding outlives
workspace membership.
These two FKs are the schema-level proof for §4.3's "unbound or
non-workspace members cannot leak content into chat_session" invariant.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): inbound services + /issue dispatcher (MUL-2671)
Lands the inbound service layer for the Lark Bot MVP, sitting on top
of the migration + service-boundary scaffold from the previous commits.
What ships:
- sqlc queries for all seven lark_* tables (idempotent dedup insert,
CAS WS-lease, single-use binding-token consume, etc.) plus
GetMostRecentUserChatMessage for the /issue fallback.
- AuditLogger backed by lark_inbound_audit; signature deliberately
body-free so callers cannot leak content into the drop log.
- ChatSessionService: find-or-create chat_session via the binding
table (winner-takes-all on the UNIQUE race), append-with-dedup, /issue
parser, "previous user message" fallback for bare `/issue` invocation.
- Dispatcher orchestrates the inbound pipeline in one place:
installation routing → group-mention filter → identity check → ensure
session → append+dedup → /issue → enqueue chat task. Group sessions
use the installer as creator (stable workspace identity); p2p uses
the sender. Agent-offline path falls through with OutcomeAgentOffline
so the WS adapter can reply with the offline notice from §4.6.
- BindingTokenService: random URL-safe token, SHA-256 stored hash,
15-min TTL pinned at the application AND the DB CHECK; Redeem
returns the same opaque error for all rejection cases (no timing
oracle on replay).
- Unit tests for the parser (13 cases), dispatcher (8 cases via fake
Queries/Chat/Audit/IssueCreator/Enqueuer), and binding-token
hash/entropy. Real-DB integration tests for OAuth + token redeem
land alongside the HTTP handlers in the next commit.
Out of scope for this commit (next ones on the same feature branch):
OAuth callback, HTTP routes, WebSocket hub, outbound card patcher,
frontend.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): installation HTTP surface + secretbox-gated wiring (MUL-2671)
Lands the HTTP boundary on top of the inbound services from the
previous commit. What ships:
- InstallationService.Upsert: the only path that writes
lark_installation. Encrypts app_secret with the secretbox passed in
at construction time; refuses to fall back to plaintext storage
(returns an error from the constructor if no Box is supplied), so a
misconfigured dev environment cannot accidentally land a row with
cleartext credentials. Revoke flips status without DELETE so audit
trail survives.
- HTTP handlers under /api/workspaces/{id}/lark/:
* GET /installations — member-visible (Integrations tab
renders for non-admins). Soft 200
with empty list + configured:false
when MULTICA_LARK_SECRET_KEY is
unset, so the tab does not error
on self-host that has not opted in.
* POST /installations — admin-only; 503 when not configured.
Re-validates agent_id ∈ workspace
before accepting credentials so a
cross-workspace agent UUID is
rejected.
* DELETE /installations/{id} — admin-only; workspace-scoped lookup
so one workspace cannot revoke
another's installation by UUID
guess.
- POST /api/lark/binding/redeem (user-scoped, no workspace context):
the only path that mints a lark_user_binding row from user action.
Redeemer identity comes from the session, not the token, so a stolen
link cannot bind an open_id to an attacker's Multica user. The
composite FK on lark_user_binding cascades the binding away if the
user is not (or no longer) a workspace member, so a non-member who
steals the link gets 403 at the DB layer.
- Two new event-bus types in protocol.events:
EventLarkInstallationCreated, EventLarkInstallationRevoked.
- Router wiring: MULTICA_LARK_SECRET_KEY drives a conditional
initialization of h.LarkInstallations + h.LarkBindingTokens. When
unset, the integration disables itself with an INFO log and the
rest of the server boots normally.
- Handler tests cover all four not-configured short-circuits.
Happy-path integration tests (real DB, full create→list→revoke
cycle and token mint→redeem) ship alongside the WS hub PR.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): close binding-token rebind & typed task errors (MUL-2671)
Two must-fixes from PR review on HEAD 87ad15e1:
1. Binding-token redeem could be used to grab an already-bound Lark
open_id. Two changes harden the path:
- lark.sql `CreateLarkUserBinding` now gates ON CONFLICT DO UPDATE
on `multica_user_id = EXCLUDED.multica_user_id`, so a cross-user
rebind via a second valid token returns zero rows instead of
silently switching ownership.
- `BindingTokenService.RedeemAndBind` consumes the token and writes
the binding row inside one transaction. A failed bind no longer
burns the token; a successful bind never leaves a consumed-but-
unused token. Distinct typed errors: ErrBindingTokenInvalid (410),
ErrBindingAlreadyAssigned (409), ErrBindingNotWorkspaceMember
(403). The handler maps each to its own status code.
2. Dispatcher collapsed every `EnqueueChatTask` error to
`OutcomeAgentOffline`, hiding infra failure and misusing the
"offline" label for cases (e.g. archived agent) where it doesn't
fit. Now:
- `service.EnqueueChatTask` returns `ErrChatTaskAgentNoRuntime` and
`ErrChatTaskAgentArchived` as sentinel errors; DB / load / insert
failures stay wrapped as ordinary errors.
- Dispatcher uses `errors.Is` to map only the productizable cases
(`OutcomeAgentOffline`, new `OutcomeAgentArchived`); any other
error is returned to the WS adapter so it can retry or page
instead of disguising the outage as an offline card.
A daemon that's merely disconnected is still NOT an error — as long
as `agent.runtime_id` is set the chat task enqueues and waits for the
daemon to claim it on next online (returns `OutcomeIngested`).
Co-authored-by: multica-agent <github@multica.ai>
* ci: re-trigger workflow on lark MVP must-fix HEAD
Co-authored-by: multica-agent <github@multica.ai>
* ci: re-trigger workflow on lark MVP must-fix HEAD (retry)
Co-authored-by: multica-agent <github@multica.ai>
* test(integrations/lark): guard binding-token sentinel contract (MUL-2671)
Two unit tests that document and protect the must-fix invariants
without requiring a DB:
1. TestRedeemAndBindRequiresTxStarter — if a future refactor wires
up BindingTokenService without a TxStarter, RedeemAndBind must
fail fast with a clear error rather than nil-panic on Begin.
The atomicity contract (consume + bind commit together) depends
on that transaction existing.
2. TestBindingErrorSentinelsAreDistinct — the HTTP handler maps
ErrBindingTokenInvalid → 410, ErrBindingAlreadyAssigned → 409,
ErrBindingNotWorkspaceMember → 403. Accidentally aliasing them
(e.g. var ErrBindingAlreadyAssigned = ErrBindingTokenInvalid)
would silently regress the response codes without any other
test catching it.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): WS hub orchestrator + outbound card patcher (MUL-2671)
The hub owns one supervisor goroutine per active installation. Each
supervisor acquires the WS lease via the existing CAS query, runs an
EventConnector (interface — real Lark wire protocol lands in a follow-up
behind it), renews the lease on a tighter cadence than the TTL, and
backs off (with jitter) on connector failure. Lease loss tears the
connector down cleanly; revocation is reaped on the next sweep. Per-
process node id satisfies §4.4 multi-replica safety: at most one Hub
globally holds the lease for any installation.
The patcher subscribes to task / chat-done events on the existing
events.Bus and keeps the per-task Lark interactive card in sync
(thinking → streaming → final | error). Card binding is per-task as
required by §4.5; throttled patches via an in-memory last-patched map;
final / error transitions bypass the throttle so the user always sees
the terminal state. The Renderer is plug-replaceable so the product
card template can evolve without touching transport.
The APIClient interface centralizes the Lark Open Platform surface
this package needs (send card, patch card, send binding prompt,
exchange OAuth code). The default stubAPIClient returns
ErrAPIClientNotConfigured for every transport call so a misconfigured
deployment fails loudly instead of dropping cards silently. Real
implementation lands in a follow-up; OAuth callback + frontend entries
land in the next commits on this branch.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): OAuth install start / callback (MUL-2671)
OAuthService builds a signed-state Lark authorization URL the frontend
can render as a QR (or open directly), then on callback verifies the
HMAC-protected state, exchanges the OAuth code for installation
credentials via APIClient.ExchangeOAuthCode, and persists the row via
InstallationService.Upsert (which keeps app_secret encryption inside a
single chokepoint).
State token format: workspaceID.agentID.initiatorID.expiresUnix.nonce.sig
— HMAC-SHA256 over the first five fields with a deployment-level
secret. TTL defaults to 10 minutes (covered by tests). Three failure
modes (invalid state / expired state / missing code) map to typed
errors so the HTTP handler can emit a single lark_error= query param
the frontend uses to pick copy.
Both endpoints degrade cleanly: the at-rest key gate (already in place)
returns 503 from /install/start when the InstallationService is nil,
and the OAuth gate (MULTICA_LARK_OAUTH_APP_ID / _SECRET / _REDIRECT_URI
/ _STATE_SECRET) returns configured:false from /install/start so the
frontend can render "configure manually instead" without an error
banner. /install/callback always finishes with a redirect to
/settings?tab=lark carrying either lark_installed=1 or lark_error=<code>.
Tests cover signed-URL shape, missing-config rejection, tampered state,
expired state, propagated exchange error, and the no-config redirect
path on the HTTP handler.
Co-authored-by: multica-agent <github@multica.ai>
* feat(views/lark): settings tab + agent bind button + /lark/bind redemption page (MUL-2671)
Adds the user-facing Lark surface across the shared packages:
- packages/core/types/lark.ts — wire shapes that mirror server/internal/
handler/lark.go. Optional fields default to undefined so older desktop
builds keep parsing if the server adds new keys (CLAUDE.md → API
Response Compatibility).
- packages/core/lark/{queries,index}.ts — Tanstack Query options keyed
by workspace id; realtime sync invalidates `installations(wsId)` on
`lark_installation:*` events.
- packages/core/api/client.ts — listLarkInstallations,
getLarkInstallURL, deleteLarkInstallation, redeemLarkBindingToken.
- packages/views/settings/components/lark-tab.tsx — Settings → Lark
panel. Listing is member-visible (matches backend); disconnect is
admin-only. Empty state points users at the per-Agent bind entry,
matching the (workspace_id, agent_id) UNIQUE: there is no
"pick an agent" UI here because the bind URL is per-agent.
- LarkAgentBindButton (same file) is the per-Agent CTA the Agent
detail page imports. Opens the OAuth URL in a new tab; the callback
bounces back to /settings?tab=lark with a query param the panel
reads for inline confirmation copy.
- packages/views/lark/bind-page.tsx — the Bot's "you need to bind"
destination. Requires session before redeeming, distinguishes the
410/409/403 backend responses into distinct copy.
- apps/web/app/lark/bind/page.tsx — Next.js route wrapping the shared
bind page in a Suspense boundary (Next 15 useSearchParams rule).
i18n: all user-facing strings land in en/zh-Hans, settings tab nav
includes a Sparkles-iconed Lark entry, bind-page copy lives under
common.lark_bind so it works pre-workspace-context too. typecheck +
lint clean.
Co-authored-by: multica-agent <github@multica.ai>
* chore(integrations/lark): wire outbound Patcher into server bootstrap (MUL-2671)
Constructs the Patcher next to the existing Installation/BindingToken
wiring in router.go and Register()s it on the event bus. With the stub
APIClient any actual transport call surfaces ErrAPIClientNotConfigured;
once the real Lark client lands, swap NewStubAPIClient for the real
implementation here without touching the Patcher's subscription logic.
doc.go updated to reflect everything the package now contains (Hub,
Patcher, OAuthService, APIClient interface). The Hub itself is NOT
booted here yet — it needs an EventConnector implementation for the
Lark long-connection wire protocol, which lands in a follow-up; the
orchestrator code and its unit tests are in place so that follow-up
can focus on the WS protocol rather than lifecycle plumbing.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): address Elon 二审 5 must-fix items (MUL-2671)
- Hub: renewer cancels run ctx on lease loss so the connector exits
even if its wire I/O is blocked, keeping the §4.4 ownership
invariant intact under lease theft.
- Hub: EventEmitter returns (DispatchResult, error) so the real
connector can post the matching Lark-side card (needs_binding,
agent_offline, agent_archived) and react to infra failures instead
of silently logging at the seam.
- Dispatcher: top-level message_id dedup runs before group filter
and identity check, so a reconnect storm cannot re-fire binding
prompts or re-spam not_addressed_in_group audit rows; the in-
AppendUserMessage dedup is removed since the table-level UNIQUE
is the ultimate backstop.
- OAuth: HandleCallback auto-binds the installer via the new
InstallerBinder seam (BindingTokenService implements it), so the
§2.1 "scan to bind, you're done" promise holds end-to-end.
validateExchangeResult now requires installer open_id; new error
reason codes wired through the callback redirect.
- Frontend / handler: install_supported listing field + StartLark-
Install short-circuit on stub APIClient hide install entry points
(Settings tab + per-agent button) while no real Lark HTTP client
is wired, so users do not land in an OAuth flow that fails at
exchange.
Includes tests for each fix (lease-loss cancel, emit error
propagation, dedup ordering, OAuth installer-bind contract, stub-
client install gate) and i18n strings for the new preview state.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): two-phase dedup so infra failures do not swallow messages (MUL-2671)
The pre-fix top-level dedup wrote the lark_inbound_message_dedup row before
EnsureChatSession / AppendUserMessage. An infra error in either step left
the row in place and a WS-adapter retry was mis-classified as a duplicate,
so the user's Lark message was permanently lost without ever landing in
chat_session.
Make dedup two-phase:
- ClaimLarkInboundDedup acquires an in-flight claim (processed_at NULL).
Stale claims older than 60 s are re-takeable so a process crash does
not strand the message_id.
- MarkLarkInboundDedupProcessed flips processed_at on durable success
(audit row OR chat_message + session touch).
- ReleaseLarkInboundDedup deletes the in-flight row on infra failure
before any durable side effect, so the retry can re-claim immediately.
Dispatcher.Handle now finalizes the claim exactly once based on whether
the inner pipeline reached a durable outcome — chat_message commit being
the transition point (errors past it Mark, errors before it Release).
Regression tests cover the two failure variants Elon flagged plus the
inverse invariants (durable-error Marks, drops Mark, in-flight replays
drop, stale claims re-claim).
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): owner-fence dedup claim to close the double-write windows (MUL-2671)
The two-phase Claim/Mark/Release fix from the previous commit closed the
"infra error swallows a replay" gap but left two windows that could still
write a chat_message twice for the same Lark message_id:
1. Stale-reclaim race. Worker A claims at t=0, runs slowly past the
60 s staleness TTL but is still alive. Worker B sees the row as
stale and re-takes the claim. A reaches AppendUserMessage and
commits a second chat_message.
2. Mark window. Worker A commits chat_message but the post-pipeline
MarkLarkInboundDedupProcessed fails (DB hiccup) or the process
crashes before it runs. 60 s later a retry treats the in-flight
row as stale, re-claims it, and writes a second chat_message.
Close both with owner fencing + same-tx Mark:
- lark_inbound_message_dedup now carries a `claim_token` UUID;
ClaimLarkInboundDedup mints a fresh one on insert and on stale
re-take, so a reclaim ROTATES the token.
- MarkLarkInboundDedupProcessed and ReleaseLarkInboundDedup are
fenced on (message_id, claim_token, processed_at IS NULL) and
return rowsAffected. Zero means our token is no longer live, and
the caller treats it as a no-op (not an error).
- AppendUserMessage invokes MarkLarkInboundDedupProcessed INSIDE its
chat_message+session tx (qtx). If the token has been rotated by a
concurrent reclaim, the Mark matches zero rows and the method
returns ErrClaimLost; the deferred Rollback unwinds the
chat_message insert, so the other holder is the sole writer. The
durable write and the Mark therefore commit (or roll back)
atomically — there is no "committed but not yet Marked" window
for a crash or retry to exploit.
Dispatcher.processClaimed now returns a tri-state dedupFinalize directive
(none / mark / release): finalizeNone for the in-tx Mark path (and
ErrClaimLost), finalizeMark for audit-drop branches and the defensive
post-Append-success fallback, finalizeRelease for pre-durable infra
errors. ErrClaimLost is translated into OutcomeDropped + DropReason-
Duplicate at the Handle boundary, matching what the WS adapter expects
for a "another worker is the writer" outcome.
Regression tests:
- TestDispatcher_StaleReclaimRaceDoesNotDoubleWrite injects worker
B's reclaim via a beforeAppend hook so the claim_token rotates
between Claim and AppendUserMessage. Asserts worker A's
AppendUserMessage returns ErrClaimLost (no chat_message
committed), the dispatcher surfaces a duplicate drop, the token
rotated to a value distinct from A's original, and a follow-up
replay still duplicate-drops.
- TestDispatcher_InTxMarkPreventsPostCommitReclaim verifies the
"Mark window" case is unreachable: a successful in-tx Mark
produces exactly one Mark call (no post-finalize duplicate), the
row is terminal, and a retry with dedupReclaim=true still
duplicate-drops without re-rotating the token.
- TestDispatcher_InTxMarkSucceedsAndSkipsPostFinalize pins the
positive contract: DedupMarked=true must make applyFinalize a
no-op (no extra Mark, no Release).
fakeQueries gains a fakeDedupRow model carrying (processed, token,
rotations) so the test seam matches production's UPDATE-with-WHERE
semantics; fakeChat gains a beforeAppend hook to inject race timing.
go test ./... and go vet ./... pass.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): real Lark HTTP APIClient for IM v1 send/patch (MUL-2671)
Lands the production Lark Open Platform HTTP APIClient that replaces
the stub for outbound transport. The patcher's "thinking → streaming
→ final | error" card lifecycle and the dispatcher's binding-prompt
card both now reach Lark for real once MULTICA_LARK_HTTP_ENABLED=true.
Scope of this stage:
- tenant_access_token retrieval via /open-apis/auth/v3/
tenant_access_token/internal, cached in-process per app_id with a
60s safety margin against Lark's `expire` value. Sub-2-minute
expires are clamped to 120s so we never cache an entry that's
already past its safe window.
- SendInteractiveCard: POST /open-apis/im/v1/messages?receive_id_type=chat_id
returning the Lark message_id the Patcher persists in
lark_outbound_card_message for later patches.
- PatchInteractiveCard: PATCH /open-apis/im/v1/messages/:id with
the full re-rendered card body (Lark's update endpoint replaces,
not deep-merges).
- SendBindingPromptCard: open_id-targeted interactive card with a
primary "去绑定" CTA pointing at the redemption URL. Template is
co-located with the transport so the dispatcher never has to know
about Lark's card schema.
- Token-error invalidation: Lark codes 99991663 (expired) /
99991664 (invalid) drop the cached token so the next call
refreshes from /tenant_access_token/internal instead of looping
on a stale entry.
Out of scope (deferred to follow-up stages):
- ExchangeOAuthCode stays unimplemented behind
ErrAPIClientNotConfigured. The PersonalAgent install handshake's
response shape (returning per-installation app credentials in a
single call) is not yet verified against the production endpoint,
and a silent mis-fill of OAuthExchangeResult would corrupt
lark_installation rows past validateExchangeResult. Operators
continue to use the manual-paste InstallationService path until
the OAuth stage lands.
- Inbound WS EventConnector — Hub's ConnectorFactory still needs a
real wire-protocol implementation.
Wiring:
- MULTICA_LARK_HTTP_ENABLED=true switches router.go from the stub
to the real client. MULTICA_LARK_HTTP_BASE_URL overrides the
default open.feishu.cn host (set to open.larksuite.com for the
Lark international tenant, or to an httptest URL for integration
tests).
- The OAuth handler now also receives the real client (its
ExchangeOAuthCode still surfaces ErrAPIClientNotConfigured, so
callback behavior is unchanged until that stage lands).
Tests (19 new cases against an httptest.Server fake):
- happy path send/patch/binding-prompt round trips, asserting URL
query params, body shape, Authorization header
- token cache: 3 sends share one /tenant_access_token/internal hit
- token refresh after clock-driven expiry
- sub-margin expire clamping (10s expire → cached for >= safety
margin of wall-clock)
- Lark error code surfacing (230001 send, 230002 patch, 10003 auth)
- token-expired (99991663) invalidates the cache; caller's retry
re-fetches and succeeds
- non-2xx HTTP status surfaces "http 500: …"
- input validation: missing chat_id short-circuits BEFORE auth
round-trip, missing card json / open_id / bind url all fail
pre-flight without hitting Lark
- ExchangeOAuthCode still returns ErrAPIClientNotConfigured
- binding-prompt template carries the BindURL and the localized
"去绑定" CTA in valid JSON
go build ./..., go vet ./..., and go test ./internal/integrations/lark/...
pass. Pre-existing handler/router integration tests that require a
real Postgres connection are unaffected by this change.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): split outbound vs OAuth-install capability + card update_multi (MUL-2671)
Address Elon's two must-fix items from the HEAD a09993b1 review:
1. HTTP outbound and OAuth-install are now distinct APIClient
capabilities. The new SupportsOAuthInstall() reports whether the
install flow can succeed end-to-end (i.e. ExchangeOAuthCode is
implemented); the real httpAPIClient still returns IsConfigured()
= true (send / patch / binding prompt work) but
SupportsOAuthInstall() = false until the PersonalAgent install-time
response shape is pinned. Handler-side `install_supported` and
StartLarkInstall now gate on SupportsOAuthInstall, so a half-wired
client never reveals the scan-to-bind UI. larkOAuthErrorReason also
maps ErrAPIClientNotConfigured to a dedicated
`oauth_exchange_unimplemented` reason so a raw callback hit no
longer masquerades as `internal_error`.
2. defaultRenderer now emits config.update_multi=true on every Kind.
Lark refuses to apply PatchInteractiveCard to a card whose initial
config doesn't declare it shared/updatable, so the absent flag
would make every patch after the first send silently no-op on the
wire while the local outbound status row still flipped to
streaming/final.
Tests cover both halves of each fix:
- TestHTTPClient_SupportsOAuthInstall_FalseUntilExchangeLands +
TestHTTPClient_StubReportsBothCapabilitiesFalse pin the new
capability surface.
- TestStartLarkInstall_TransportOnlyClientReportsNotConfigured +
TestListLarkInstallations_TransportOnlyClientReportsInstallNotSupported
pin the handler gate at exactly the half-wired state.
- TestLarkOAuthErrorReason_APIClientNotConfigured pins the mapping
for both the bare sentinel and the fmt.Errorf-wrapped form
HandleCallback produces.
- TestDefaultRendererConfigCarriesUpdateMulti covers every CardKind.
- TestHTTPClient_(Send|Patch)InteractiveCard_DefaultRendererBodyHasUpdateMulti
verify the wire body Lark actually receives carries update_multi
through both send and patch transport paths.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): real OAuth code exchange + agent-detail bind entry (MUL-2671)
Stages the install side of the MVP critical path on top of the real
HTTP outbound work:
- httpAPIClient.ExchangeOAuthCode runs the production Lark v2 OAuth
flow: POST /authen/v2/oauth/token to swap the authorization code
for the installer's open_id, then GET /bot/v3/info under the parent
app's tenant_access_token to fetch bot_open_id. Result feeds
InstallationParams unchanged so OAuthService.HandleCallback's
auto-bind step lights up automatically.
- HTTPClientConfig gains OAuthAppID/OAuthAppSecret, read from the same
MULTICA_LARK_OAUTH_APP_ID/_APP_SECRET env vars the OAuthConfig
consumes. SupportsOAuthInstall now mirrors that pair so the install
capability gate is honest: outbound transport without OAuth creds
reports configured-but-not-install-supported, exactly like before.
- Agent detail inspector wires the LarkAgentBindButton in a new
Integrations section, viewer-hidden by canEdit. The button still
self-hides when SupportsOAuthInstall is false, so a deployment
without OAuth creds renders the section empty rather than CTA-broken.
- Capability wording cleaned across handler / router / lark-tab to say
"OAuth-install capability" instead of "real APIClient wired", and
the misleading TransportOnly... test was renamed/refocused on the
early-return branch it actually exercises (Elon non-blocking note).
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): identity-only OAuth + atomic bind (MUL-2671)
Addresses Elon's round-4 must-fix items on PR #3277:
1. OAuth v2 token → user_info chain now matches Lark's official
user-OAuth shape. `httpAPIClient.ExchangeOAuthCode` POSTs
/open-apis/authen/v2/oauth/token (RFC 6749: top-level
access_token, NO open_id), then GETs /open-apis/authen/v1/user_info
with the user_access_token as Bearer to obtain the installer's
open_id / union_id. The test fixture now reflects the real
wire shape (separate user_info handler; no synthetic open_id in
the token response).
2. `OAuthExchangeResult` is identity-only — drops the synthesized
shared-parent AppID / AppSecret / BotOpenID return that broke
the UNIQUE(app_id) constraint and the dispatcher's per-app_id
routing. `OAuthService.HandleCallback` no longer Upserts an
installation row: it looks up the lark_installation already
provisioned via the manual-paste POST /lark/installations route
and binds the installer onto it. Two new typed errors —
ErrInstallationNotProvisioned and ErrInstallationRevoked — map
to `installation_not_provisioned` / `installation_revoked`
reasons at the HTTP boundary so the UI can guide the admin.
The PersonalAgent install API (which would deliver
per-installation bot credentials at scan time) remains a
follow-up; until it lands the OAuth flow is identity-binding
only and the agent-detail bind button stays hidden on
deployments without OAuth env (capability gate unchanged).
3. The installation lookup + installer bind run inside a single
DB transaction so a concurrent revoke / re-provision between
the read and the binding insert cannot leak a half-applied
state. `InstallerBinder.BindInstaller` is renamed to
`BindInstallerTx` and accepts the OAuth-service-owned
transaction's qtx; the binding_token redemption path is
unchanged.
4. `validateExchangeResult` is simplified to require only the
installer's open_id; the obsolete ErrExchangeMissingAppID /
AppSecret / BotOpenID sentinels are removed (no caller can
trip them now). The oauth_test suite is rewritten to use a
stub failTxStarter so tests covering state-token verification
and exchange-error propagation remain DB-free, while a new
TestOAuthCallbackOpensTxAfterValidExchange pins the post-must-fix
order (state ok + exchange ok ⇒ Begin runs before any lookup
or bind, and a Begin failure aborts cleanly with no bind).
Verified locally:
- go build ./... / go vet ./... clean
- go test ./internal/integrations/lark/... ✓
- go test ./internal/handler -run 'Lark|Binding|OAuth' ✓
- go test ./internal/util/secretbox/... ./internal/service/... ✓
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): device-flow scan-to-install (MUL-2671)
Replaces the manual paste-credentials install path + identity-only
OAuth callback (rejected in product review: too many steps before a
user sees value) with a true single-step scan-to-install built on
Lark's RFC 8628 device-flow registration endpoint
(POST accounts.feishu.cn/oauth/v1/app/registration) — the same
protocol the official larksuite/oapi-sdk-go/scene/registration
package and zarazhangrui/feishu-claude-code-bridge use.
User journey: admin clicks "Bind to Lark" on the Agent detail page
→ QR dialog opens → admin scans in the Lark app on their phone →
authorizes the new PersonalAgent → dialog auto-closes with the new
installation visible. No app_id / app_secret to copy, no Lark
developer console visit, no Multica-side OAuth env to configure.
Backend (server/internal/integrations/lark):
- registration.go — inline ~280-line RFC 8628 client. Begin posts
archetype=PersonalAgent / auth_method=client_secret /
request_user_info=open_id; Poll follows the upstream SDK's
state machine including the tenant-brand mid-stream domain swap
to accounts.larksuite.com when a Lark-international account
authorizes. SDK is NOT vendored — one endpoint isn't worth
dragging the full oapi-sdk-go + transitive deps.
- registration_service.go — owns the in-process session store
+ background polling goroutine. On success calls APIClient.GetBotInfo
(the new IM-side endpoint added below) and writes
lark_installation + the installer's lark_user_binding inside
one DB transaction so a half-applied install can never land.
Stable error_reason codes (expired / access_denied /
lark_protocol_error / bot_info_failed / installation_conflict /
installer_bind_failed / internal_error) drive the UI copy
without parsing prose.
- client.go / http_client.go — drops ExchangeOAuthCode and
SupportsOAuthInstall (no longer applicable: device-flow returns
identity alongside credentials in one response); adds GetBotInfo
which mints a tenant_access_token from the freshly-minted
client_id / client_secret and calls /open-apis/bot/v3/info for
the bot_open_id. install_supported now gates on IsConfigured()
(real HTTP client wired) instead of a separate OAuth capability.
- binding_token.go — absorbs InstallerBindParams / InstallerBinder
(previously in oauth.go), retargets the doc-comment from the
OAuth caller to the device-flow caller.
- Deletes oauth.go + oauth_test.go entirely.
Handler & router (server/internal/handler, server/cmd/server):
- POST /api/workspaces/{id}/lark/install/begin — opens a new
registration session, returns {session_id, qr_code_url,
expires_in_seconds, poll_interval_seconds}. Admin-only.
- GET /api/workspaces/{id}/lark/install/{sessionId}/status —
polling endpoint, returns {status, installation_id?, error_reason?,
error_message?}. Workspace-scoped lookup so a stolen session_id
cannot be polled from another workspace. Admin-only.
- Removes POST /lark/installations (paste form),
GET /lark/install/start (OAuth-redirect entry), and
GET /api/lark/install/callback (OAuth redirect target).
- Removes MULTICA_LARK_OAUTH_APP_ID / _APP_SECRET / _REDIRECT_URI /
_STATE_SECRET / _AUTHORIZE_URL / _SUCCESS_URL env vars. Self-host
operators no longer need a parent Lark app at all.
Frontend (packages/core, packages/views):
- New types BeginLarkInstallResponse / LarkInstallStatusResponse
+ matching API methods (beginLarkInstall / getLarkInstallStatus);
drops getLarkInstallURL.
- LarkAgentBindButton opens LarkInstallDialog instead of a
window.open() to Lark's authorize page. The dialog uses
react-qr-code (catalog) to render the verification_uri_complete
inline as SVG (no external CDN image), polls status at the
server-supplied cadence, auto-closes on success, offers
"scan again" on terminal failure. Per CLAUDE.md "Enum drift
downgrades, not crashes", error_reason switch has a default
fallback so an older desktop build on a newer server still
renders the generic failure copy.
- Adds the device-flow strings to en + zh-Hans settings.json;
removes the obsolete OAuth-not-configured copy.
Verified locally:
- go build ./... / go vet ./... clean
- go test ./internal/integrations/lark/... — all green
(existing tests + 15 new registration / GetBotInfo tests)
- go test ./internal/handler -run 'Lark|Binding' — all green
- pnpm typecheck — all 6 packages clean
- pnpm lint — 0 errors (15 pre-existing warnings, none in changed files)
- pnpm --filter @multica/views test — 859/859 pass
Pre-existing failures in server/internal/middleware (column
"profile_description" missing from local test DB) reproduce against
the parent commit and are unrelated to this change.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): gate bind CTA to workspace admins, terminate QR polling on 4xx (MUL-2671)
Two frontend must-fixes from the PR #3277 二审:
1. LarkAgentBindButton now self-hides for non-admin viewers in addition
to the existing install_supported check. The agent-detail page mounts
the button under `canEdit`, which canEditAgent lets agent owners
through even when they are not workspace admins — but the backend
gates POST /lark/install/begin and the status poll on owner/admin
(router.go:478-487), so the previous behavior shipped a CTA that was
guaranteed to 403. The new gate reads workspace role from the same
member list the settings tab already uses.
2. The status polling loop now terminates on 404 (session gone — server
restarted, multi-instance routing, or in-process GC swept it) and
403/401 (permission revoked mid-session). Previously every error
path scheduled another setTimeout, which trapped the user on a stale
QR forever. ApiError gives us the HTTP status verbatim; terminal
responses set status=error with stable error_reason codes
(session_lost, forbidden) that flow through the existing dialog
switch + retry/close affordances. 5xx + network blips still retry.
i18n: new install_error_session_lost / install_error_forbidden in en
and zh-Hans, with default fallback preserved per the enum-drift rule.
Coverage: 6 new vitest cases — admin/owner allow, member deny,
unsupported-install deny, and the two terminal-error polling paths
using fake timers to assert the loop stops scheduling.
Also clears a handful of stale OAuth/manual-install doc comments
flagged in the review (non-blocker cleanup): doc.go's §10 now points
at RegistrationService, installation.go's input-shape doc loses the
OAuth-callback half, and client.go's stubAPIClient comments no longer
reference OAuth callbacks.
Co-authored-by: multica-agent <github@multica.ai>
* docs(integrations/lark): describe gate as device-flow install in agent-detail integrations comment (MUL-2671)
The comment block above the agent-detail Integrations section still
described the capability gate as 'server-side OAuth-install'. The
OAuth path is gone — install is now device-flow per RFC 8628 — so the
comment now reads 'server-side device-flow install capability gate'.
Pure comment change; behavior is unchanged. Cleans up the nit Elon
called out in PR #3277 二审 (MUL-2671).
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): wire inbound pipeline + WS Hub at boot (MUL-2671)
Stage 3.a of MUL-2671. Hub class, Dispatcher, ChatSessionService and
AuditLogger have all been implemented and tested in prior PRs but
none of them was constructed at boot, so the in-process plumbing
was never exercised end-to-end. This change wires them together
behind the same `MULTICA_LARK_SECRET_KEY` gate that already gates
InstallationService / RegistrationService, and starts the Hub under
the existing `sweepCtx` so it winds down alongside the other
long-running workers after HTTP drain.
The real long-conn EventConnector is still pending; the factory
hands every supervisor a shared NoopConnector that holds the lease
and emits nothing. That lets staging exercise the lease /
supervisor / shutdown lifecycle against real DB rows without
committing to the Lark wire protocol implementation. Swapping in
the real connector is a single line change in the same router
block; the Dispatcher / ChatSessionService / Hub seams stay frozen.
## Why a noop placeholder, not a stub-or-skip
The Hub's value is mostly its lifecycle: §4.4 ownership lease,
LeaseRenewInterval / LeaseTTL, supervisor reap on revoke, clean
release on shutdown. None of that runs unless the Hub is actually
started. Holding off until the real connector lands means the next
PR has to debut both pieces simultaneously; wiring the supervisor
loop first lets the real connector PR be a focused, reviewable
swap.
## Changes
- `internal/integrations/lark/noop_connector.go` — `NoopConnector`
implementing `EventConnector`: blocks on ctx until the Hub
cancels (lease loss / shutdown / revoke), emits no events, logs
on enter/exit so operators see exactly which installation the
supervisor is holding the lease for.
- `internal/integrations/lark/noop_connector_test.go` — verifies
the connector blocks until ctx cancel, returns nil on clean exit,
never invokes the emit callback, and the factory shares a single
connector instance across installations.
- `internal/handler/handler.go` — new `LarkHub *lark.Hub` field on
`Handler`. Nil when the Lark integration is disabled.
- `cmd/server/router.go` — inside the existing Lark wiring block,
construct `AuditLogger`, `ChatSessionService` (with `*pgxpool.Pool`
for the in-tx dedup Mark), `Dispatcher` (wiring `h.IssueService`
and `h.TaskService` so `/issue`-created issues share counter /
duplicate guard / project boundary / broadcast / analytics with
the rest of the product), and the `Hub` with the
`NoopConnectorFactory`. `NewRouterWithOptions` now returns
`(chi.Router, *handler.Handler)` so main.go can drive Hub
lifecycle; `NewRouter` discards the handler.
- `cmd/server/main.go` — start the Hub under `sweepCtx` after the
other background workers, and `Wait` on it after HTTP drain +
sweep cancel so the lease renewer can issue a final release
before exit. Skipped entirely when `h.LarkHub == nil`.
## Test plan
- [x] `go build ./...` clean
- [x] `go vet ./...` clean
- [x] `go test ./internal/integrations/lark/...` (new noop tests +
existing hub / dispatcher / chat_service / registration /
binding_token / outbound / issue_command suites) — all pass
- [x] `go test ./internal/handler -run 'TestLark|TestRedeemLarkBinding'`
pass — handler-side Lark surfaces unchanged
- [x] `go test ./internal/service/... ./internal/util/secretbox/...`
pass
- [x] `pnpm --filter @multica/views exec vitest run settings/components/lark-tab`
pass (6/6) — frontend lark surfaces unchanged
- [ ] Local broad `go test ./internal/handler/...` still blocked by
the pre-existing test DB schema drift Elon flagged in the
previous round (`column "metadata" does not exist`,
unrelated to this change); CI is the authoritative check.
- [ ] Manual end-to-end deferred until the real long-conn
EventConnector lands (next stage).
MUL-2671
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): bound Hub lease release + shutdown wait (MUL-2671)
Lease release used context.Background(); a stalled DB pool could pin
shutdown indefinitely. Add LeaseReleaseTimeout (5s default) and
ShutdownTimeout (15s default) to HubConfig, route releaseLease through
a bounded context, and expose WaitWithTimeout for main.go so a wedged
supervisor degrades to LeaseTTL expiry on the next replica instead of
blocking process exit. Also correct the LarkHub field comment in
handler.go: the Hub is wired whenever the at-rest secret key is set,
independent of whether the outbound HTTP APIClient is configured.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): real WS long-conn connector + ctx-cancel-breaks-read (MUL-2671)
Replaces NoopConnectorFactory with a production EventConnector that
opens Lark's event-subscription WebSocket. Gated behind
MULTICA_LARK_WS_ENABLED so staging boots stay on the noop path until
operators opt in, and falls back to noop with a warning when the WS
flag is set without MULTICA_LARK_HTTP_ENABLED (the real connector
needs the cached tenant_access_token).
Why this connector exists separately from the Hub: gorilla/websocket
ReadMessage blocks on the underlying TCP socket and does not observe
context. The watchdog goroutine inside WSLongConnConnector.Run closes
the conn the moment ctx fires, so lease loss / shutdown breaks the
blocking read in bounded time — exactly the invariant Hub
renewLeaseUntil's runCancel depends on for the "at most one active WS
per installation across replicas" guarantee. Tests cover this
explicitly (TestWSConnectorRunReturnsOnCtxCancelEvenWhenReadIsBlocked).
The Lark wire surface is split into three swappable seams so the
transport layer stays tested in isolation:
- EndpointFetcher (POST /event-subscription/v1/connection_token)
resolves a one-shot wss URL per Run. No caching — replaying a
one-shot token would look like a Lark outage.
- FrameDecoder turns one raw JSON envelope into an InboundMessage
or a "control / heartbeat / drop" verdict. Decoder errors log
+ drop the frame; they do NOT tear down the connection.
- CredentialsProvider wraps InstallationService.DecryptAppSecret
so plaintext app_secret lives in memory only during a Run.
Also fixes the handler.go LarkHub comment: it still said "joins on
Wait during graceful shutdown" but main.go has used WaitWithTimeout
(bounded wait) for several commits. Comment now matches.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): align WS to official binary Frame protocol + DispatchResult outbound replies (MUL-2671)
Two must-fix items from Elon's review of PR #3277:
1. WS protocol layer rewritten to match the official Lark Go SDK
(`larksuite/oapi-sdk-go/v3/ws`):
- Bootstrap is `POST /callback/ws/endpoint` with AppID/AppSecret
in the body (no tenant_access_token bearer). Response carries
wss URL + ClientConfig (PingInterval / ReconnectInterval /
ReconnectNonce / ReconnectCount).
- `service_id` is parsed from the wss URL query and used as
Frame.Service on every outbound frame.
- Wire envelope is the binary protobuf `pbbp2.Frame` (hand-rolled
via protowire to avoid pulling the whole SDK in, byte-identical
field tags). JSON payloads are nested inside Frame.Payload.
- Inbound data frames are ACKed with a `Response{code:200,...}`
JSON payload that reuses the inbound headers; infra failures
produce code=500 so Lark retries.
- Ping is the app-layer binary `NewPingFrame(serviceID)` at the
server-supplied cadence; WebSocket protocol PING is removed
(Lark ignores it). Server-initiated pings get a pong reply.
- ctx-cancel-breaks-read invariant preserved via the watchdog
goroutine that closes the conn on ctx.Done; the read loop and
ping goroutine serialize their writes through a single mutex.
2. `DispatchResult` outbound replies wired via a new `OutcomeReplier`:
- `OutcomeNeedsBinding` mints a one-shot binding token and sends
the binding prompt card to the sender's open_id.
- `OutcomeAgentOffline` / `OutcomeAgentArchived` push a notice
card into the chat with the agent name + Chinese copy matching
§4.6.
- `OutcomeIngested` stays owned by the Patcher; `OutcomeDropped`
is silent.
- The replier is best-effort: outbound failures are logged and
swallowed so a Lark outage cannot stall the inbound pipeline.
- Hub installs the noop replier by default; router wires the
production `LarkOutcomeReplier` when APIClient.IsConfigured().
PersonalAgent long-conn risk surfaced (open per Feishu docs:
`长连接模式仅支持企业自建应用`). The implementation works for any
app archetype; the open question is whether `/callback/ws/endpoint`
accepts PersonalAgent credentials in practice. Surfacing the Lark
code+msg verbatim from the bootstrap response so an operator running
the smoke test sees the exact failure rather than a generic timeout.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): byte-compat Frame marshal, chunk reassembly, ACK off reply critical path (MUL-2671)
Three protocol blockers from Elon's review of 9540008a:
1. Frame.Marshal is now byte-identical to oapi-sdk-go/v3/ws/pbbp2.Frame:
- SeqID/LogID/Service/Method (proto2 req) emit unconditionally even at zero
- PayloadEncoding/PayloadType/LogIDNew emit unconditionally per gogo
generated MarshalToSizedBuffer (no zero-guard)
- Payload uses the SDK's `!= nil` guard (nil omits, []byte{} emits 0-length)
- ACK payload JSON matches SDK's NewResponseByCode + json.Marshal output
({"code":N,"headers":null,"data":null})
Golden tests pin exact byte sequences for ping/pong/ACK/full/zero
frames; verified against the real SDK pbbp2.pb.go MarshalToSizedBuffer
producing identical bytes.
2. Multi-frame events (sum>1) are reassembled via the new chunkAssembler:
- 5s sliding TTL (matches SDK combine() cache TTL)
- Lazy GC on admit (no separate sweeper goroutine)
- Out-of-order seq + duplicate seq idempotent
- Partial chunks are NOT ACKed (SDK behaviour: only the final chunk's
ACK confirms the whole event so Lark can retry on partial loss)
- Connector wires assembler per-Run; state dies with the session
3. OutcomeReplier detached from ACK critical path:
- HubConfig.ReplyTimeout default 2.5s, strictly under Lark's 3s ACK deadline
- handleEvent dispatches synchronously (fast DB path), then spawns the
replier under a fresh background ctx with WithTimeout(ReplyTimeout)
- Hub.replyWg tracks in-flight replies; Hub.Wait / WaitWithTimeout
drain them so shutdown is bounded
- Noop replier short-circuits inline (no goroutine cost when outbound
APIClient isn't configured)
Proof tests:
- TestHubScheduleReplyReturnsImmediately: scheduleReply with a 10s
slow replier returns in <50ms
- TestHubReplyTimeoutCancelsHungReplier: hung replier ctx fires at
ReplyTimeout
- TestHubWaitDrainsInFlightReplies: Wait blocks until replies finish
- TestHubACKNotBlockedByOutboundReply: end-to-end through the
connector — data-frame ACK lands within 500ms even when the
replier hangs 5s
PersonalAgent real-env smoke remains Bohan's decision; this PR closes
the technical blockers Elon flagged.
Co-authored-by: multica-agent <github@multica.ai>
* docs(service/issue): narrow position concurrency claim to create-create (MUL-2671)
Elon's review of the merge resolution flagged that the comment on the
new NextTopPosition call promised more than the code guarantees:
concurrent manual reorder via UpdateIssue(position) does NOT take the
workspace row lock that IncrementIssueCounter holds, so a create
racing a reorder can still land on the same position. Rewrite the
comment to only claim create-create serialization, which is the
behaviour the lock actually delivers. No code change.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): keep device-flow polling on RFC 8628 HTTP 400 (MUL-2671)
Lark's device-flow polling endpoint returns HTTP 400 with the JSON
body `{"error":"authorization_pending"}` while the user hasn't scanned
the QR yet — this is the RFC 8628 spec, and the upstream oapi-sdk-go
implements the same handling. Our previous doForm treated ANY non-2xx
as a terminal protocol error, so every install session was killed by
the first poll (~5s after begin) and the install dialog appeared
silently empty: the frontend received status=error +
lark_protocol_error before the user could even read the description.
Fix: doForm now decodes the JSON body first; if it parses, the caller
(Begin / Poll) routes on the body's `error` field, where the existing
switch correctly maps authorization_pending / slow_down to "keep
polling" and access_denied / expired_token to terminal failure. Only
unparseable bodies (5xx HTML proxy pages, gateway timeouts) still
surface as a typed http_NNN RegistrationError.
Three regression tests pin the new behaviour:
- HTTP 400 + authorization_pending → res.Status="authorization_pending"
- HTTP 400 + access_denied → res.Err.Code="access_denied" (terminal)
- HTTP 502 + HTML body → http_502 RegistrationError
Verified against the live local env: install/begin -> 200, status
stays "pending" through the first poll cycle, no longer flips to
"error" within seconds.
Co-authored-by: multica-agent <github@multica.ai>
* fix(views/lark): reset closedRef on every mount so StrictMode double-mount renders QR (MUL-2671)
Empty QR dialog body in the dev env: Bohan opened the bind dialog and
got an empty white area where the QR should have been — no QR, no
"starting" placeholder, no error text. Backend was returning the QR
URL correctly; the bug was on the frontend.
Root cause: React 19 / Next.js dev StrictMode mounts every component
twice (mount → cleanup → mount). The component instance is REUSED
across the simulated remount, which means useRef objects are
preserved. The dialog's `closedRef` lifecycle:
1. Mount #1: closedRef={current:false}, beginSession() kicked off
(HTTP request still in flight)
2. Cleanup runs: closedRef.current=true
3. Mount #2: beginSession() kicked off again, BUT the ref still
reads {current:true} from step 2
4. Both promises resolve. Both hit the post-await guard
`if (closedRef.current) return;` and bail out before setSession().
5. Result: session stays null forever. Every conditional in the
dialog body (beginning/session-pending/success/error) is false →
empty body.
Fix: reset closedRef.current=false at the START of the effect, not
just at component construction. The cleanup-then-mount pair now
re-arms the guard so subsequent setSession calls actually land.
Regression test wraps the dialog in <StrictMode> and asserts the
QR appears within 2s with the correct value — fails closed if anyone
removes the reset.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): drop EventTaskCompleted subscription so the chat reply doesn't get overwritten by "Done." (MUL-2671)
Bohan reproduced on the live dev env: agent replies show only a card
saying "Done." in Lark, even though Multica's own chat panel has the
real "Hello! I'm cc…" reply. Tasks succeed end-to-end, but the user
loses the reply on the Lark side.
Root cause: TaskService.CompleteTask publishes two events for every
chat task IN ORDER:
1. broadcastChatDone(...) → ChatDonePayload{Content: "Hello!..."}
2. broadcastTaskEvent(Completed) → map[string]any{task_id, agent_id,...}
(no `content` key)
The Patcher subscribed to BOTH and routed each to finalize(). The
first patch correctly rendered the reply text, the second
patched the same card with an empty payload — chatDoneContent()
returned "" and the renderer fell back to "Done." (default empty-body
copy). The second patch wins because Lark stores whatever was last
applied.
Fix: stop subscribing to EventTaskCompleted in the Patcher and remove
the corresponding switch arm. EventChatDone is the canonical "agent
finished replying" signal for the Lark card path; EventTaskCompleted
is still emitted to the bus for other listeners (web UI, analytics,
task usage) where the lack of content doesn't matter.
Regression test TestPatcherIgnoresEventTaskCompletedForChatTasks
emits ChatDone followed by TaskCompleted on a streaming card and
asserts: exactly one patch, body contains the agent reply, body does
NOT contain "Done.". If anyone re-adds the EventTaskCompleted
subscription, this fails immediately.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): chat replies as plain text IM messages, not card chrome (MUL-2671)
Bohan reported on the live dev env that even with the agent's reply
shown correctly, every message is wrapped in an interactive card with
the agent name as the header — it feels like a system notification,
not a normal chat reply. He wants the reply to land as a regular Lark
text bubble.
Changes:
- Add APIClient.SendTextMessage backed by Lark's
/open-apis/im/v1/messages with msg_type=text. JSON-encodes the
{"text": ...} envelope Lark requires so callers pass raw strings.
- Patcher.Register no longer subscribes to EventTaskQueued /
EventTaskRunning. There is no more thinking → running → final
card lifecycle on the success path: it added card chrome without
buying anything for free-form chat.
- On EventChatDone, the new sendChatReply path posts the assistant
message content as plain text. Empty content is silently dropped
rather than rendered as "Done." (the prior fallback that
confused Bohan).
- Failure path keeps a one-shot error card on EventTaskFailed —
the visual distinction from a normal reply is genuinely useful,
and failures are rare enough that the chrome isn't noisy.
- Throttle / lastPatched map / MinPatchInterval / shouldPatch /
markPatched / loadCardOrSkip are all removed; nothing in the new
flow patches.
Tests:
- TestPatcherSendsPlainTextOnChatDone pins the new contract: exactly
one SendTextMessage call, no card sends or patches, content
matches the ChatDonePayload.
- TestPatcherDropsEmptyChatReply pins the "no more Done. fallback"
decision — empty content drops, period.
- TestPatcherFailEventSendsErrorCard pins the failure path still
uses a card (one-shot, no patching).
- TestPatcherIgnoresEventTaskCompletedForChatTasks rewritten for
text path: ChatDone then TaskCompleted yields exactly one text
send, no duplicate.
- TestPatcherSkipsWhenNoChatSessionBinding and
TestPatcherSwallowsInstallationLoadErrors rewritten to drive
EventChatDone (the new entry point) instead of TaskQueued.
- TestPatcherSendsThinkingCardOnTaskQueued deleted (no more
thinking card).
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): pre-fill PersonalAgent bot name as "<agent> - Multica" (MUL-2823) (#3520)
The device-flow install left the bot at Lark's auto-generated
"{用户姓名}的智能助手". Lark's registration scene supports pre-filling the
name via a `name` query param on the verification/QR URL (mirrors the
upstream SDK's AppPreset.Name) — a user-editable default that rides on
the QR URL, not the begin POST body (which has no name field).
BeginInstall already loads the agent for its ownership check, so we keep
it and thread `<agent.Name> - Multica` through Begin → decorateQRCodeURL.
A blank name degrades to plain "Multica".
There is no post-install rename API (bot/v3 is read-only; no
bot/v3/update), so the install-time pre-fill is the only programmatic
lever; the user can still edit the name on the creation form.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): restore /issue confirmation + pin SendTextMessage wire (MUL-2671)
Two recovered/added contracts off Trump's review of HEAD fe381a07:
1) /issue confirmation in Lark was a casualty of the plain-text
refactor. The pre-refactor `RenderInput.IssueNumber` field was
declared but never actually rendered into the card body, so even
in the original card-based flow the user never saw a "Created
[MUL-42]" confirmation. Now the OutcomeReplier handles
OutcomeIngested + IssueID.Valid by sending a plain text message:
Created MUL-42 — fix login bug
https://multica.example/issues/MUL-42
Composed from a new DispatchResult.IssueIdentifier +
IssueTitle, populated by the Dispatcher from
workspace.IssuePrefix + issue.Number / issue.Title. Workspace
lookup is best-effort: a Postgres blip on workspace gets a "#42"
fallback rather than silently dropping the confirmation.
The agent's own chat reply (if any) continues to land separately
via the Patcher on EventChatDone — these are two semantically
distinct messages and the user benefits from seeing both.
2) SendTextMessage is the wire layer Trump flagged for missing
coverage. Three new wire tests pin:
- happy path: POST /open-apis/im/v1/messages?receive_id_type=chat_id,
msg_type=text, Bearer <tenant_access_token>, double-JSON
content envelope
- special-character round trip: newlines, double quotes,
backslashes, tabs, Chinese + emoji, JSON-lookalike strings.
The inner {"text": ...} is encoded once at JSON.Marshal time
and once again when the outer body serializes; losing either
pass corrupts the message and the bug is invisible without a
contract pin.
- Lark error path: non-zero `code` surfaces as a wrapped error
with the code embedded.
Tests:
- TestDispatcher_IssueCreationFromCommand asserts IssueIdentifier
("MUL-42") and IssueTitle propagate through DispatchResult.
- TestDispatcher_IssueIdentifierFallsBackToNumberOnWorkspaceLookupErr
pins the "#7" degrade-graceful fallback.
- TestLarkOutcomeReplierIssueCreatedSendsConfirmation pins the
text body (identifier + title + deep link) and asserts no card
send on this path.
- TestLarkOutcomeReplierOutcomeIngestedSilentWithoutIssue pins
the silent-on-plain-chat default so we don't accidentally start
emitting a confirmation for every message.
- TestHTTPClient_SendTextMessage_* covers the wire contract.
Frontend locale parity (en + zh-Hans, 53 tests) is currently green
on this HEAD; no changes needed.
Co-authored-by: multica-agent <github@multica.ai>
* fix(views/locales): add missing ko keys for Lark MVP (MUL-2671)
Trump flagged on PR #3277 review that the ko bundle was missing the
Lark-MVP-only keys that en + zh-Hans both carry. The parity test
caught it cleanly after main was merged in (Korean PR landed on main
between the prior review and this one):
common.lark_bind.* (13 keys)
settings.page.tabs.lark (1 key)
settings.lark.* (45 keys)
agents.inspector.section_integrations (1 key)
Korean translations are professional/concise — "Lark" stays as the
brand name (matches how en keeps "Lark" + "(飞书)" parenthetically;
ko/users searching for the product expect "Lark"), and product copy
follows the zh-Hans tone where Multica nouns ("에이전트", "워크스페이스")
are romanized loan words consistent with the rest of the ko bundle.
Slot ordering preserved against EN:
- page.tabs.lark sits between github and integrations
- inspector.section_integrations sits right after section_skills
Verified: pnpm exec vitest run locales/parity → 105/105 pass.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): /issue origin_type CHECK + Hub restart on credentials rotation (MUL-2671)
Two live-env bugs Bohan reproduced:
1) /issue command crashed the WS connector. Dispatcher writes
origin_type='lark_chat' on issues born from `/issue`, but the
issue_origin_type_check CHECK constraint was last extended in
migration 060 for quick_create — it doesn't list lark_chat, so
every Lark /issue tripped SQLSTATE 23514 and bubbled up as an
infra error. The infra error tore down the WS connector, Lark
retried the same message, the new connector tripped the same
constraint and crashed again. Repro in the live env: three
crashes from the same /issue event over ~40s, each leaving the
user with no confirmation in Lark.
Migration 111 extends the CHECK list:
CHECK (origin_type IN ('autopilot', 'quick_create', 'lark_chat'))
2) Re-scanning an already-bound agent silenced the bot. The device
flow re-registers with Lark, which mints a brand-new bot (fresh
app_id + app_secret); RegistrationService.finishSuccess upserts
into lark_installation by agent_id, so the row's credentials
rotate in place. But the running supervisor held the OLD inst
struct by value and kept a WS open against the OLD bot's app_id —
so all events to the NEW bot went nowhere. Bohan's "claude code
现在不能在飞书里回复了" symptom maps exactly to this:
log timeline:
16:29:57 cc connector connected with app_id=cli_aa9398dd... (OLD)
16:34:07 lark registration: install complete (rotation)
→ row.app_id is now cli_aa93f36f... (NEW)
→ old WS still subscribed to OLD app_id; new app_id receives nothing
Fix: Hub.sweep now compares each installation row's credentials
fingerprint (app_id + bot_open_id + sha256(app_secret_encrypted))
against the snapshot the running supervisor was started with. On
diff, cancel the old supervisor and start a fresh one inline. A
monotonic gen counter on the supervisor entry disambiguates the
old goroutine's deferred cleanup from the new entry the rotation
path already swapped in.
Tests:
- TestHubRestartsSupervisorOnCredentialsRotation pins the new path:
starts hub on app_one, rotates the row to app_two, asserts the
connector factory is called again with the fresh AppID.
- TestHubDoesNotRestartSupervisorOnUnchangedRow pins the negative
case so an unchanged row doesn't degenerate into a per-sweep
busy-loop.
- Existing hub tests (lease, supervise, shutdown, ACK timing,
noop replier) all green.
Verification:
- go test ./internal/integrations/lark/... -race -count=1 ok
- go build ./... clean
- migration applied locally; \d+ issue confirms lark_chat in CHECK
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): per-supervisor lease token to fence rotation handoff (MUL-2671)
Elon flagged a race in HEAD be8d4cef's rotation path: both the old
and the new supervisors of the same Hub used the hub-wide nodeID as
their WS lease token, so an old supervisor's post-cancel
releaseLease(nodeID) would CAS-match the lease row the successor had
just acquired with the SAME token and DELETE it. Symptom would be a
silently empty lease row a few hundred ms after every device-flow
re-scan — no replica owning the install, no events delivered, the
"bot goes quiet" pattern Bohan hit the first time but now from the
fencing side rather than the credentials side.
Fix: leaseToken(nodeID, gen) composes "<nodeID>-g<gen>", where gen is
the monotonic counter already attached to each supervisorEntry. The
nodeID prefix keeps cross-replica observability (an operator
inspecting lark_installation.ws_lease_token can still map back to a
process) while the -g suffix makes the OLD supervisor's release
target the OLD row state. Once the rotation path swaps in the new
supervisor, the row's CurrentToken is the new -g(N+1) token, so the
old -gN release's WHERE clause no-ops instead of clobbering.
acquireLease / renewLeaseUntil / releaseLease now take an explicit
token argument; supervise threads its leaseToken through. The
plumbing isn't pretty, but having an explicit argument at every call
site is the only way the rotation invariant survives subsequent
refactors — without it, a future caller could quietly reintroduce
"just use h.nodeID" and the race is back.
Two regression tests:
- TestHubRotationStaleReleaseDoesNotClearSuccessorLease drives the
fake lease state machine directly:
1. old acquires(tokenA)
2. rotation lands; new acquires(tokenB)
3. old's stale release(tokenA) fires
Asserts owner ends up still tokenB. Hub-wide-nodeID code would fail
step 3 by clearing the entry.
- TestHubRotationEndToEndKeepsSuccessorLeased runs the same scenario
through the live supervise loop: starts hub, rotates the row, waits
for sup2 to take over with a distinct token, sleeps past sup1's
unwind, asserts the row is still held by a non-sup1 token. Catches
the bug even when the goroutine timing is non-deterministic.
Verification: go test ./internal/integrations/lark/... -race -count=1 ok
go build ./... clean
go vet ./... clean
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): route group @-mentions via union_id, not open_id (MUL-2671)
In a Lark group with multiple Multica bots installed, the bot whose WS
received the event sometimes failed to recognize that it was the @-target
while the OTHER bot's supervisor falsely fired. Bohan's controlled three-
message test (only @A, only @B, @both) hit this: @A and @B alone went
unanswered, @both got picked up by A only.
Root cause: the `mentions[].id.open_id` field Lark puts on the WS event
is structurally INVERSE to `/bot/v3/info`'s `bot.open_id` across the two
WSes. From A's WS perspective, the wire-form open_id for "A was @-ed"
is NOT equal to A's API-side open_id, but IS equal to what B's WS sees
on its side, and vice versa. The decoder's `mention.open_id ==
inst.BotOpenID` match therefore fires on the wrong bot in multi-bot
groups. Only `union_id` (the Lark-tenant-scoped stable identifier) is
consistent across both WSes.
Changes:
- migration 112 adds nullable `lark_installation.bot_union_id`
- sqlc query exposes UpsertLarkInstallation/CreateLarkInstallation
with bot_union_id, plus a focused SetLarkInstallationBotUnionID for
the backfill path
- httpAPIClient.GetBotInfo now follows /bot/v3/info with /contact/v3/
users/{open_id}?user_id_type=open_id and returns both identifiers
on BotInfo. Soft-fails on contact-scope denial: install still
succeeds with an empty UnionID, and the decoder falls back to the
legacy open_id match for single-bot deployments.
- RegistrationService.finishSuccess persists union_id alongside
open_id during the device-flow finalize.
- ws_frame_decoder.containsMention prefers union_id and only walks
open_id when the installation row has not been backfilled yet.
- BackfillBotUnionIDs runs once at server boot for installations
created before migration 112; bounded per-row 10s timeout and a
pure soft-fail policy so a slow Lark round-trip cannot block
startup.
- regression tests cover the three decoder paths: union_id match
wins over open_id mismatch, union_id mismatch overrides open_id
match, and open_id fallback when union_id is unknown.
Co-authored-by: multica-agent <github@multica.ai>
* chore: drop trailing blank lines at EOF on four files (MUL-2671)
git diff --check origin/main..origin/pr-3277 flagged these as new
blank lines at EOF; clearing so the diff stays clean for review.
Co-authored-by: multica-agent <github@multica.ai>
* fix(views/locales): add missing ja keys for Lark MVP + section_integrations (MUL-2671)
CI frontend job tripped on the ja locale parity check: ja is missing
the lark_bind block in common.json, the lark block + page.tabs.lark
in settings.json, and inspector.section_integrations in agents.json.
The ko fix earlier covered Korean; ja was added separately on main
and the merge surfaced these gaps. Translations mirror the en source
and follow the same voice as the existing ja bundle.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): rewrite @_user_N placeholders into clean body (MUL-2671)
When Lark dispatches a group `im.message.receive_v1`, the message
text contains opaque `@_user_1`, `@_user_2`, … placeholders and the
real identity is in `mentions[]`. We were forwarding the raw text to
the agent, so a Bohan-typed "@Bot ping test" arrived as "@_user_1
ping test" — neither human-readable nor useful as LLM context, and
the agent was paying tokens to figure out which `@_user_N` was even
itself.
The new resolveMentions pass:
* strips the bot's own mention entirely (the dispatcher already
routes the event on AddressedToBot; re-emitting @<self> in front
of every message adds zero signal and pollutes context),
* substitutes other participants with `@<displayName>` so a
follow-up "@Alice" reads naturally,
* collapses horizontal whitespace introduced by the strip while
preserving original newlines.
Bot identity check uses the same union_id-preferred + open_id
fallback as containsMention, so the rewrite stays consistent with
the routing path. Tests cover the four shapes: bot self-mention,
mixed bot + other-user mention, multi-line body with stripped
mention, and a no-mention body that should be left untouched.
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): union_id-first self mention strip + token-aware scan + local whitespace cleanup (MUL-2671)
Three review blockers on the mention rewrite from PR review:
1. isBotMention now mirrors containsMention's union_id-first policy.
When the installation row knows our union_id, we trust it
exclusively (open_id is structurally inverted in multi-bot
groups — matching on it would re-introduce the routing bug we
fixed two commits ago). open_id fallback fires only when
union_id is absent. New tests: @-ing both bots in one message
correctly strips only self and renders the sibling as @<name>;
open_id-matches-but-union_id-differs does NOT strip.
2. resolveMentions no longer collapses or trims whitespace globally.
Indentation, tabs, code blocks, tables — all preserved verbatim.
When the self mention is removed we eat exactly one adjacent
horizontal space (the one after the placeholder, or, when the
mention sits at end-of-input, a single space already emitted
right before it). New test exercises a multi-line indented +
tabbed body and asserts the whole shape survives.
3. Prefix-collision-safe replacement. A chat with 11+ participants
exposes both `@_user_1` and `@_user_10`; naive ReplaceAll for
`@_user_1` would mangle the substring of `@_user_10`. The
resolver now does a single-pass token scan with the mention
list sorted longest-key-first, so the longer placeholder always
wins at any scan position. New test covers the @_user_1 /
@_user_10 case explicitly.
Also drops the temporary INFO-level diag logging the previous
commit added — root cause was confirmed (union_id swap in the
manual backfill; not a decoder bug).
Co-authored-by: multica-agent <github@multica.ai>
* fix(integrations/lark): scope inbound dedup per (installation_id, message_id) (MUL-2671)
Root cause of the residual "@Cc gets dropped as not_addressed_in_group"
even after the union_id swap landed: lark_inbound_message_dedup was
keyed on `message_id` alone. In a Lark group chat where the workspace
has multiple Multica bots installed, Lark delivers the SAME message_id
to every bot's WS supervisor. Whichever WS claimed first then ran its
own AddressedToBot check; the bot that was actually @-ed lost the dedup
race, found the row already terminal (`processed_at IS NOT NULL`), and
was dropped as `duplicate` BEFORE it could evaluate its own mention.
Net: every @ silently disappeared if Lark happened to route the OTHER
bot's WS first.
The dedup gate's original purpose (idempotency against WS reconnect
replay) is per-installation by definition, so the right key is
composite (installation_id, message_id).
Changes:
- migration 113 drops + recreates lark_inbound_message_dedup with
installation_id NOT NULL REFERENCES lark_installation(id) ON DELETE
CASCADE and PRIMARY KEY (installation_id, message_id). The table is
a 24h transient cache, so dropping existing rows is safe.
- sqlc queries: ClaimLarkInboundDedup / MarkLarkInboundDedupProcessed /
ReleaseLarkInboundDedup all now take installation_id.
- AppendUserMessageParams carries InstallationID through to the
in-tx Mark call so the chat_message+dedup atomicity stays intact.
- Dispatcher passes inst.ID to claim + applyFinalize + AppendUserMessage.
- Test fakes key dedup state on (installation_id, message_id) via a
composite map key; all existing pre-seeded rows use a seedDedupKey
helper bound to the default activeInstallation fixture so the prior
staleness / token-rotation / in-tx mark tests still exercise the
same regression they did before.
- New regression TestDispatcher_DedupIsScopedPerInstallation pins the
multi-bot invariant: a row pre-seeded for installation A does NOT
block installation B's first delivery of the same message_id; B
runs through its own group-filter / identity / ingest pipeline.
Co-authored-by: multica-agent <github@multica.ai>
* feat(integrations/lark): render markdown chat replies via schema-2.0 card (MUL-2671)
The agent's chat replies were going out as msg_type=text, so every
`**bold**`, fenced code block, list, table, and link in the body
showed up as literal markdown characters in Lark — the user saw raw
asterisks, hashes, pipes instead of formatted text. Bohan reported
this and pointed at zarazhangrui/lark-coding-agent-bridge as the
shape to emulate.
The bridge repo uses Lark interactive cards with the schema-2.0
envelope and a `tag: "markdown"` body element; Lark's client
renders that to formatted text (GFM-ish: bold/italic, headings,
lists, links, fenced code blocks, tables, blockquotes). They expose
multiple reply modes (card / markdown-as-post / text) gated by user
config; we go a step simpler — auto-detect markdown syntax in the
agent's body and route accordingly:
- containsMarkdown(): cheap substring + regex pass for fenced code
blocks, headings, list markers, bold/italic, tables, links,
blockquotes, horizontal rules, inline code. Biases toward false-
positive — wrapping prose in a card still renders fine, but
missing a real markdown block leaves raw characters visible.
- APIClient gains SendMarkdownCard / SendMarkdownCardParams.
Implementation marshals the schema-2.0 envelope verbatim:
{schema:"2.0", body:{elements:[{tag:"markdown", content: md}]}}.
Stub returns ErrAPIClientNotConfigured.
- Patcher.sendChatReply now branches on containsMarkdown:
markdown → SendMarkdownCard, plain prose → SendTextMessage. A
one-liner "sure, on it" stays as a normal IM bubble (no card
chrome); anything with markdown gets the rendered card.
Tests: TestContainsMarkdown pins the heuristic across plain prose
and ten markdown shapes; TestPatcherRoutesMarkdownReplyToCard and
TestPatcherRoutesPlainReplyToText cover the router; new HTTP wire
test TestHTTPClient_SendMarkdownCard_HappyPath contract-pins the
card envelope (msg_type=interactive, schema 2.0, markdown tag,
verbatim body). Full lark suite passes.
Co-authored-by: multica-agent <github@multica.ai>
* fix(service/issue): route analytics.IssueCreated through obsmetrics.RecordEvent (MUL-2671)
CI's TestNoNakedAnalyticsCaptureInHandlersOrServices guard caught the
post-merge analytics call in IssueService.captureCreatedAnalytics
that still used s.Analytics.Capture(...) directly. Main added that
lint to prevent the Prometheus and PostHog sides from drifting — any
new analytics.* event must go through obsmetrics.RecordEvent so the
business-metrics collector and the PostHog client fire from the same
call site.
Fix mirrors how TaskService handles it: IssueService gains a
Metrics *obsmetrics.BusinessMetrics field (router wires it via
h.IssueService.Metrics = opts.BusinessMetrics next to the existing
TaskService line), and the in-service Capture call becomes
obsmetrics.RecordEvent(s.Analytics, s.Metrics, ...). nil-safe by
construction — RecordEvent treats a nil Metrics as PostHog-only.
Co-authored-by: multica-agent <github@multica.ai>
* feat(views/lark): swap Bind CTA for Connected+Manage link when agent already has an installation (MUL-2671)
Bohan reported the agent-detail Bind button keeps inviting the user to
re-scan the QR even when the agent already has an active Lark
PersonalAgent connected — and re-scanning silently upserts the
installation row, leaving the previously-created Lark bot dangling
as a zombie. Frustrating UX and an actual product footgun.
Anti-zombie guard at the only entry point: LarkAgentBindButton now
checks the cached installations listing for an active row pinned to
this agent_id. When one exists, the install CTA is gone — replaced
by a small Connected pill + an "Manage in Lark" link that opens the
Bot's app page in Lark's developer console (open.feishu.cn/app/<app_id>)
in a new tab. That's where scopes, display name, and additional
permission requests actually live; re-scanning never was the right
answer for managing an existing bot.
Scoping is per-agent: an active installation on a DIFFERENT agent
in the same workspace doesn't affect this agent's button, and a
revoked installation falls back to the bind CTA so the user can
re-create. Tests cover all four states (own-active / own-revoked /
other-agent-active / no-installation) and pin the Manage link's
href + target=_blank + noopener.
i18n: three new keys in settings.json (en / zh-Hans / ja / ko):
agent_bot_connected_label, agent_bot_manage_link,
agent_bot_manage_tooltip. Locale parity test still 157/157.
The dev console host is hardcoded to open.feishu.cn — operators
on the Lark international tenant currently get the wrong host;
future-proof fix wants the backend to surface a per-installation
dev_console_url on the listings response, called out in a code
comment.
Co-authored-by: multica-agent <github@multica.ai>
* feat(views/settings): collapse Lark into Integrations + render agent identity (MUL-2671)
Lark was its own top-level workspace settings tab while Integrations sat
empty next to it. As more integrations land, the sidebar would balloon
with one tab per provider. Move the Lark surface into Integrations as
the first hosted integration; the old ?tab=lark URL redirects through
LEGACY_WORKSPACE_TAB_REDIRECTS so bookmarks still resolve.
The Connected bots list was leaking the raw Lark app_id (cli_…) as the
row title with bot_open_id (ou_…) underneath — meaningless to product
users. Since the binding is 1:1 with a Multica Agent, join on agent_id
and render the agent's avatar + name via the workspace-standard
ActorAvatar + useActorName.getAgentName. Deleted agents fall back to
"Unknown Agent" so the row is still actionable for cleanup.
Tests: stub useActorName + ActorAvatar in lark-tab.test.tsx and add
LarkTab connected-bot tests covering the agent identity render and the
deleted-agent fallback. Drop the now-dead integrations.* + page.tabs.lark
+ lark.bot_open_id_label keys across all four locales — parity still
157/157, views suite 1141/1141.
Co-authored-by: multica-agent <github@multica.ai>
* feat(views/settings): wrap Lark in a named section inside Integrations (MUL-2671)
Integrations is meant to host multiple providers (Slack, Linear etc. as
they land), so the Lark content should sit under a Lark heading rather
than fill the tab directly — otherwise the first additional integration
would feel like it broke the IA. Add a "Lark" / "飞书" section heading
above LarkTab using the same h2 chrome the other settings tabs use, and
pin lark.section_title across all four locales (parity 169/169).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
* Optimize chat message loading
Co-authored-by: multica-agent <github@multica.ai>
* Fix chat history cursor pagination
Co-authored-by: multica-agent <github@multica.ai>
* Fix chat session list remount key
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): fall back to legacy /messages when paged endpoint 404s
Deployment-order compatibility: a backend deployed before the
/messages/page endpoint existed returns 404 for the unknown route.
The cursorless initial page now falls back to the legacy full-list
/messages endpoint and wraps it in a single has_more:false page, so
chat never white-screens regardless of which side deploys first. A 404
on a cursor request still propagates to avoid duplicating the full list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Backfill the missing query invalidations (chat / labels / invitations) in invalidateWorkspaceScopedQueries, so those lists refresh on WS reconnect and workspace switch instead of showing stale data until a manual refresh.
Adds tests covering invalidation on ws-instance change and actor_type passing to event handlers.
MUL-2882
Adds avatar_url column to workspace, threads it through the API +
WorkspaceAvatar component, and adds a click-to-upload editor in the
workspace settings tab. Mirrors the squad avatar pattern (migration 086);
UI strings use "logo" while the schema/code uses avatar_url for codebase
consistency with user.avatar_url and squad.avatar_url.
- migration 093: ALTER TABLE workspace ADD COLUMN avatar_url TEXT
- UpdateWorkspace SQL + handler accept avatar_url (auth gated to
owner/admin at the router via RequireWorkspaceRoleFromURL)
- WorkspaceAvatar renders <img> when avatar_url is set, falls back to
the initial-letter span otherwise
- workspace-tab.tsx adds a 16x16 click-to-upload logo editor at the
top of the general settings card, using useFileUpload + accept=
image/png,image/jpeg,image/webp (server stores under workspaces/{id}/)
- en + zh-Hans settings i18n strings added
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Expose self-host daemon setup URLs from /api/config at runtime so the Add computer dialog renders the operator's own server/app domains, while Multica Cloud defaults stay unchanged.
Fixes#3013.
* feat(billing): test page consuming /api/cloud-billing/*
Stuffs every cloud-billing endpoint onto a single dev page so we can
verify the proxy + Stripe end-to-end flow without a designed UI.
Reachable at /<workspaceSlug>/billing — the page is account-level
data but lives under the workspace dashboard layout because that's
where the authenticated shell sits. No sidebar entry on purpose;
this is test-quality and meant to be deleted when the real billing
UI ships.
What's there:
* Balance card (GET /balance)
* Stripe-success polling banner — visible only when ?session_id=
is in the URL (Stripe substitutes it into checkout_success_url
on its way back). React Query refetchInterval polls every 2s
until the topup status reaches credited / failed / canceled,
then a 'Clear from URL' button calls navigation.replace(pathname).
* Buy section: server-authoritative price tier buttons (GET
/price-tiers) → POST /checkout-sessions → window.location to the
Stripe URL. We do NOT hard-code amounts on the frontend; tier
config lives in cloud's billing.price_tiers.
* Stripe Billing Portal button (POST /portal-sessions). Opens in a
new tab so the originating page stays put for easy verification.
Documented behaviour: 400 is expected for users with no Stripe
customer record yet.
* Three lists: transactions / batches / topups.
Plumbing:
* packages/core/types/billing.ts — interfaces mirroring the cloud
response shapes. Status / source / tx_type fields are typed
'string' rather than enum unions to match the schemas' z.string()
parsing (same convention as CloudRuntimeNode); the canonical
enum values are exported as separate type aliases for callers
that want to switch on them.
* packages/core/api/schemas.ts — 9 zod schemas + 7 EMPTY_ fallbacks,
all .loose() so a non-breaking cloud-side field addition doesn't
crash the parser.
* packages/core/api/client.ts — 8 methods using parseWithFallback,
matching the existing cloud-runtime shape.
* packages/core/billing/{queries,mutations,index}.ts — React Query
queryOptions + mutations. Notable choices: balance / lists are
NOT keyed on workspace (account-level data), and the
checkout-session polling stops automatically when status is
terminal so we don't poll forever after a user closes the tab.
* packages/core/package.json + packages/views/package.json — exports
map updated for @multica/core/billing and @multica/views/billing.
Verification:
* pnpm --filter @multica/core typecheck clean
* pnpm --filter @multica/views typecheck — only pre-existing
hast-util-to-html error in editor code (exists on main)
* pnpm --filter @multica/core test — 412 passing
* pnpm --filter @multica/views test — 877 passing, 1 failure
(editor/readonly-content) is also pre-existing on main, not
caused by this change
Out of scope: real production-quality billing UI; sidebar entry; i18n
strings; mobile app. This is a single test page; it gets replaced
when the real UI ships.
* fix(billing): refetch balance/lists when checkout polling reaches terminal
Closes the second-half of the Stripe-return race the previous commit
left dangling.
Symptom:
After Stripe redirects back with ?session_id=..., the banner polls
/checkout-sessions/{id} every 2s and the rest of the page (balance,
transactions, batches, topups) is fetched once on mount. The
webhook race means those four queries usually see pre-credit state
— but the banner is the only thing that keeps polling, so once it
reads 'credited' nothing else on the page knows. The user would
see 'Final status: credited' next to a stale balance card until
they manually refresh.
Fix:
Add useInvalidateBillingDataAfterCredit() in @multica/core/billing —
a hook returning a callback that flushes balance / transactions /
batches / topups (NOT the checkout-session itself; its
refetchInterval already terminated, refetching would just confirm
the same value). The Stripe-success banner runs this callback in
a useEffect keyed on terminal-status transition, so it fires
exactly once when the polling lands.
Strict scope is documented in the hook's JSDoc:
- balance/transactions/batches: only change at the 'credited'
transition (cloud writes ledger + batch + wallet in one DB tx)
- topups: changes on every terminal transition
- For 'failed' / 'canceled' we technically over-fetch the first
three; three cheap round-trips, simplifies the call site, fine
on a test page.
Effect dep is . terminal flips false→true at most once
per session id (the polling stops when terminal is true so the
data won't change again). If the user lands here with a session
that is already terminal (re-opened tab on a credited URL), the
effect still fires on first data load and we still re-fetch —
correct, the cached snapshot is just as stale in that case.
go build / pnpm typecheck / pnpm test clean (core 412 passing; only
pre-existing hast-util-to-html error in unrelated editor code on
views, same as on main).
* feat(self-host): DISABLE_WORKSPACE_CREATION env var (MUL-2777, #3433)
When self-hosters set DISABLE_WORKSPACE_CREATION=true, POST /api/workspaces
returns 403 for every caller and the UI hides every "Create workspace"
affordance (sidebar, modal, /workspaces/new page, onboarding Step 2). This
closes the gap where ALLOW_SIGNUP=false still let any signed-in user open
an isolated workspace the platform admin couldn't see.
- server: new Config.DisableWorkspaceCreation, gate in CreateWorkspace,
workspace_creation_disabled in /api/config, Go tests.
- frontend: new workspaceCreationDisabled in configStore, hide sidebar
entry, swap NewWorkspacePage / CreateWorkspaceModal / onboarding
StepWorkspace to a "creation disabled, ask for invite" state when the
flag is on, EN + zh-Hans locale strings.
- ops: .env.example, docker-compose.selfhost, helm values + configmap,
SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md, environment-variables docs
(EN + zh).
Co-authored-by: multica-agent <github@multica.ai>
* fix(onboarding): drive create path off workspaceCreationAllowed (#3433)
PR #3441 review: when DISABLE_WORKSPACE_CREATION=true and the user already
has a workspace, StepWorkspace still walked the resume copy (`headline_resume`
/ `lede_resume` mentioning "or start another") and `creatingActive` ignored
the flag, leaving a stale clickable create CTA possible if /api/config
arrived late.
Refactor StepWorkspace to derive a single `workspaceCreationAllowed`
boolean from the config store. It now drives:
- Initial `mode` state (defaults to "existing" when disabled + reusing so
the CTA is pre-armed for the only valid action).
- `creatingActive` so the footer CTA cannot fall back into the create
branch even mid-render.
- Eyebrow / headline / lede strings — adds
`creation_disabled_{eyebrow,headline,lede}_resume` (EN + zh-Hans) for
the disabled + reusing variant.
Tests: cover the three reachable shapes — flag off + no existing, flag on
+ no existing, flag on + existing.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(auth): support mcn_ Cloud Node PATs verified via Fleet
Adds a new token kind, mcn_ (multica cloud node), recognized in both
the regular Auth and DaemonAuth middlewares. mcn_ tokens are minted
and owned by Multica Cloud (not the local personal_access_tokens
table); the server validates them by POSTing to the Fleet's
/api/v1/pat/verify endpoint and uses the returned owner_id as
X-User-ID for downstream handlers.
Cloud is the authoritative owner of token status, so this is a
verifier-only path with no DB fallback:
* Fleet says valid:false -> 401 (token genuinely bad)
* Fleet unreachable / 5xx -> 503 (transient, retry)
* No MULTICA_CLOUD_FLEET_URL configured -> 401 (fail closed)
Verification results are cached in Redis for 60s under
mul:auth:mcn:<sha256> to bound the per-request load on Fleet without
extending the revocation window beyond what the Cloud doc allows.
Negative results are NOT cached, so a freshly minted token doesn't
get locked out by a stale 'token_not_found'.
Reuses MULTICA_CLOUD_FLEET_URL (the same env the cloud-runtime proxy
already uses) so deployments don't need a second config knob.
Tests cover the happy path, every documented invalid reason, 4xx/5xx
mapping, network error, decode error, ctx cancellation, the
fail-closed valid:true-without-owner_id case, trailing-slash URL
normalization, and the Redis cache short-circuit + negative
no-cache contract. Middleware tests pin the four 401/503/200 outcomes
in both Auth and DaemonAuth.
* auth(mcn): require owner_id to map to a real local user; drop X-User-PAT plumbing
Two related changes:
1. Cloud-verified owner_id is now checked against our local users table.
The Cloud owner_id and our users.id share the same UUID space by
contract; a missing local user means either the row was deleted
under an active node or something is forging owner_ids — either
way, fail closed.
CloudPATVerifier.Verify takes a new OwnerLookupFunc:
- returns (true, nil) -> success, cache + return
- returns (false, nil) -> ErrCloudPATInvalid (reason='owner_unknown'),
NOT cached (so a freshly-created user
doesn't get locked out for a TTL window)
- returns (_, error) -> ErrCloudPATUnavailable (transient,
middleware emits 503)
Both Auth and DaemonAuth wire ownerLookupFor(queries), a new shared
helper that wraps queries.GetUser, mapping pgx.ErrNoRows / unparseable
UUIDs to (false, nil) and other errors to a real Go error.
2. Removed all X-User-PAT plumbing. Cloud now mints node-scoped mcn_
PATs itself during /api/v1/nodes (see multica-cloud
docs/api/node-pat.md) and ships them into the EC2 instance via SSM,
so multica-api no longer needs to forward the caller's mul_ PAT.
Propagating a long-lived user PAT into a remote machine widened
the blast radius of any node compromise; that's gone now.
Removed:
- cloud_runtime.go: withUserPAT option, cloudRuntimeUserPAT,
generateCloudRuntimePAT, revokeGeneratedPAT
- cloudruntime/Request.UserPAT field + X-User-PAT header
- X-User-PAT from CORS allowed headers
- obsolete handler tests:
TestCreateCloudRuntimeNodeForwardsValidatedPAT
TestCreateCloudRuntimeNodeRejectsUnownedPAT
TestCreateCloudRuntimeNodeRejectsExpiredPAT
TestCreateCloudRuntimeNodeAutoGeneratesPAT
replaced with TestCreateCloudRuntimeNodeForwardsBody
- X-User-PAT references in packages/core/api/client.test.ts
Tests:
* 3 new verifier-level tests (owner_unknown not cached, lookup error
-> Unavailable, success path is cached for both fleet AND lookup)
* 5 new owner_lookup_test.go tests (nil queries, existing user,
missing user, malformed UUID, DB error)
* 1 new end-to-end DaemonAuth test (cloud says valid, no local user
-> 401)
* Existing X-User-PAT TS assertions removed; full vitest run passes.
* go test ./... and go vet ./... clean on the server module.
* fix(create-issue): preserve parent_issue_id through Create with agent flow (MUL-2534)
When the create-issue modal was opened from the "Add sub issue" entry on
an existing issue and the user switched to "Create with agent", the
parent_issue_id was silently dropped: switchToAgent only forwarded
prompt + actor + project_id, the AgentCreatePanel had no notion of
parent context, and the daemon prompt never instructed the agent to
pass --parent <uuid>. The sub-issue intent was lost and the new issue
landed as a standalone.
This fix threads parent_issue_id through the whole pipeline silently —
no new editable form field, the existing carry channel handles it:
- Frontend: ManualCreatePanel.switchToAgent + AgentCreatePanel.switchToManual
now carry parent_issue_id (and identifier, for display) so the sub-issue
intent survives mode flips in either direction. AgentCreatePanel reads
parent from `data`, forwards to api.quickCreateIssue, and renders a
read-only "Sub-issue of MUL-XX" chip so the user can see the relationship.
- API: quickCreateIssue accepts optional parent_issue_id.
- Backend: QuickCreateIssueRequest validates parent_issue_id belongs to the
same workspace (same path as CreateIssue), persists it in
QuickCreateContext, and the daemon claim handler resolves the parent's
identifier for prompt context.
- Daemon prompt: when ParentIssueID is set, buildQuickCreatePrompt instructs
the agent to pass `--parent <uuid>` and treat the modal entry point as
authoritative.
Tests cover all three hops: switchToAgent carry payload, AgentCreatePanel →
api.quickCreateIssue, and the daemon prompt's --parent injection (with both
identifier-present and UUID-only fallback branches).
Co-authored-by: multica-agent <github@multica.ai>
* test(create-issue): cover quick-create parent trust boundary + identifier fallback (MUL-2534)
Address review on PR #3083:
- Add server-side test for POST /api/issues/quick-create parent_issue_id:
same-workspace parent threads through QuickCreateContext.ParentIssueID,
foreign-workspace and bogus UUIDs return 400 and never enqueue a task.
- Fall back to `data.parent_issue_identifier` in ManualCreatePanel's
switchToAgent when the parent detail query hasn't hydrated yet, so the
agent chip never renders "Sub-issue of " with an empty tail.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(project): add local_directory project_resource type (MUL-2662)
Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.
Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.
Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.
Co-authored-by: multica-agent <github@multica.ai>
* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)
Addresses the Elon review on PR #3263:
- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
matching handler, CLI `project resource update`, and a new
EventProjectResourceUpdated WS event. resource_type stays immutable;
ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
embedded label differs — the row-level UNIQUE only matches the full
ref JSON, so a label typo would otherwise let the same working
directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
invalid path) and the label-shadow conflict on both create and
update; the in-place rename still succeeds because the conflict
scan ignores the row being edited.
Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.
Co-authored-by: multica-agent <github@multica.ai>
* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)
Two follow-ups from MUL-2662 review round 2:
- CreateProject inline resources path now dedupes local_directory entries on
(daemon_id, local_path) before opening the transaction. The DB-level
UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
full JSON match, so two rows with the same target but different `label`
would otherwise slip past. Standalone POST/PUT already cover this via
findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
row before applying per-type shortcut flags, so `--default-branch-hint x`
on its own no longer constructs a payload missing `url` (which the server
400s on). Local_directory partial edits get the same merge behavior.
Co-authored-by: multica-agent <github@multica.ai>
* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)
* feat(desktop): local_directory project_resource UI (MUL-2665)
First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.
What's new for the renderer:
- ProjectResourcesSection grows a desktop-only "Add local directory"
button next to the existing GitHub-repo popover. Clicking it opens
Electron's native folder picker, validates the path through a new
IPC pair (existence + r/w), and submits a project_resource of
resource_type=local_directory with daemon_id pulled live from
daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
greys out when ref.daemon_id != this machine's daemon_id (with a
"only available on the machine that registered this directory"
tooltip). Delete stays enabled so users can drop stale registrations
from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
shows "Agent will work in-place at {label} ({path})" when the issue's
project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
that the daemon will publish when it dequeues a task but can't
acquire the path lock. The render is in place now so the daemon
sibling subtask can wire the status string without an additional UI
PR.
Plumbing:
- @multica/core/types gains LocalDirectoryResourceRef +
UpdateProjectResourceRequest, and the api client gets the matching
PUT method backed by the server endpoint that landed in
2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
local-directory:pick -> dialog.showOpenDialog (openDirectory)
local-directory:validate -> stat + access(R_OK + W_OK)
exposed through the preload as desktopAPI.pickDirectory /
validateLocalDirectory. View code talks to them via a thin
packages/views/platform helper that returns reason=unsupported on
web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
running flag from daemonAPI.onStatusChange so the renderer can do the
cross-device match without coupling to the desktop preload typings.
Tests:
- pickStageKeys gets a unit test covering the new stage and proving
the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
and chat.status_pill.stages.waiting_for_directory_release in both
locales.
Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
new state.
- Adding local_directory to the create-project modal's bulk
attach flow.
- Docs page refresh for project-resources.mdx — left for the
MUL-2618 umbrella sweep.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)
Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)
* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)
Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:
- Validates the path (absolute, exists, daemon process can read+write,
not in the system-root / $HOME blacklist) and fails the task fast on
any precondition violation, with a user-readable reason.
- Serialises concurrent tasks on the same on-disk path via a
daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
lock is held for the entire task lifetime (claim → context write →
agent → result report).
- When the lock is contended, the daemon flips the row to a new
waiting_local_directory status on the server (carrying a wait_reason
like "<path> (held by task <short id>)") so the UI can render
"等待本地目录释放" instead of leaving the row silently in dispatched
past the sweeper timeout. The status accepts being woken into running
once the lock is acquired.
- Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
.gc_meta.json — the daemon's logbook for the run.
- Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
gcActionOrphan → gcActionSkip). The user's directory was never under
envRoot to begin with, so this is defense in depth.
- Skips execenv.Reuse for local_directory tasks because the prior
WorkDir is the user's path and reusing it through that code path
loses the envRoot association the GC loop needs. Prepare is cheap
here (no clone, no copy), so always running it is fine.
Server-side protocol changes:
- New CHECK value 'waiting_local_directory' on agent_task_queue.status
plus a wait_reason TEXT column (migration 109).
- All cancel / active / counted-as-running / orphan-recovery queries
expanded to include the new status; FailStaleTasks intentionally
excludes it (the daemon owns the wait).
- New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
StartAgentTask that accepts both dispatched and
waiting_local_directory as preconditions (and clears wait_reason on
the way through).
- New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
daemon Client.MarkTaskWaitingLocalDirectory.
Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.
The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.
This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)
Address the 3 must-fix items from Elon's review of PR #3274.
1. Status string unified. The server / daemon publish
`waiting_local_directory`; align views, locales, and the
pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
on a placeholder string). Without this, the daemon's wait state
never reached the pill once the two siblings merged.
2. validateLocalPath now also runs the blacklist against the
symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
redirect handled via `isBlacklistedRealPath` which compares
canonical forms. Without this, a symlink such as
`/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
while every daemon write still landed in the user's home. Tests
cover symlink-to-home, symlink-to-system-root, and the negative
case (symlink to a regular subdirectory).
3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
watcher inside `onWait` (lazy — the fast path stays free) so the
gap between dispatch and StartTask responds to server-side cancel
or row deletion. If the watcher fires while the daemon is parked
on the path mutex, the lock-wait context is cancelled, Acquire
returns promptly, and the helper exits silently the same way the
run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
exercises the path end-to-end with a fake server.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)
- validateLocalPath now always runs isBlacklistedRealPath on the
symlink-resolved path, not only when it differs from absPath. The old
guard let users type the canonical form of an OS-symlinked banned root
(e.g. /private/tmp, /private/etc, /private/var on macOS) straight
through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
onto filepath.VolumeName via a new isDriveRoot helper, so removable /
network drives mounted at G:..Z: and UNC \\server\share roots are also
blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
new TestIsDriveRoot covers the Windows generalisation (skipped on
POSIX runners by runtime guard).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)
Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.
- Add `waiting_local_directory` to `AgentTask.status` and the typed
`task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
status label, treat parked tasks the same as dispatched for time anchor /
transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
dispatched and queued, and surface a "is waiting for the local directory"
banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
`execution_log.status_waiting_local_directory` (en + zh-Hans).
Co-authored-by: multica-agent <github@multica.ai>
* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)
The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:
- server: `findLocalDirectoryConflict` rejects any second row sharing a
daemon_id, regardless of `local_path` or label. Bundled-create surface in
`CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
one row pinned to the current daemon (older API client / direct DB
writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
daemon owns a row on this project, with a hint and a defensive toast on
the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
* daemon: new `two local_directory rows on this daemon fail fast` /
`local_directory rows on different daemons coexist` cases.
* handler: rewrite the legacy `LabelShadow` cases as
`DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.
Co-authored-by: multica-agent <github@multica.ai>
* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)
Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.
Co-authored-by: multica-agent <github@multica.ai>
* feat(create-project): binary source picker — repos OR local directory
Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.
Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
only emits the resource matching the active sourceMode, so abandoned
picks never leak into the created project.
Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.
* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica J <j@multica.ai>
* feat(runtimes): cascade-archive agents on runtime delete (MUL-2667)
Replace the bare 409 "cannot delete runtime: it has active agents" with a structured response carrying the blocking agent list, and wire a cascade endpoint that archives those agents, cancels their tasks, pauses dangling autopilots and deletes the runtime in a single transaction. The unified DeleteRuntimeDialog opens directly in cascade mode when the runtime has bound agents, pivots from light to cascade if the strict DELETE refuses with runtime_has_active_agents, and re-prompts when the cascade refuses with runtime_delete_plan_changed (live agent set drifted while the dialog was open). The online-local self-healing rule is preserved at the affordance level (kebab hidden, Diagnostics button disabled with tooltip) and re-checked at confirm time as defence in depth.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): close cascade race + i18n delete dialog (PR #3266 review)
- Acquire FOR UPDATE on the runtime row at the top of the cascade tx so
FK-validated agent INSERTs/UPDATEs that would point at this runtime
block until commit, and lock each currently-active agent row via
ListActiveAgentsByRuntimeForUpdate so a concurrent archive/move of
an existing active row also blocks.
- Switch the bulk archive from runtime-keyed (ArchiveAgentsByRuntime)
to ID-keyed (ArchiveAgentsByIDs), narrowed to the user-confirmed
expected_active_agent_ids set. Combined with the runtime row lock,
this guarantees no agent outside the confirmed plan can be silently
archived between plan-compare and archive even at read-committed.
- Wire delete-runtime-dialog.tsx to runtimes locale via useT(); add
detail.delete_dialog.{light,cascade} keys (EN with _one/_other
plurals, zh-Hans _other) covering titles, descriptions, warning,
notices, checkbox, buttons, table headers, presence labels, and
toasts. Resolves the i18next/no-literal-string CI failure.
- Locale parity test passes (51 tests). All 4 dialog test cases pass
unmodified (EN copy preserves original wording). Full views vitest:
91 files / 792 tests green; full server go test: green.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* refactor(editor): split rich text styles
* feat(issues): server-side sort + fix drag position corruption in non-manual sort
Backend: ListIssues and ListGroupedIssues now accept `sort` and `direction`
query params (position/priority/title/created_at/start_date/due_date).
ListIssues converted from sqlc to hand-written SQL for dynamic ORDER BY.
Priority sort uses CASE expression for semantic ordering.
Frontend: query keys include sort so changing sort triggers server refetch.
Client-side sortIssues() removed from board-view and list-view.
Drag-and-drop: non-manual sort disables within-column reorder (prevents
silent position corruption). Cross-column drag only updates status/assignee,
preserves original position. Column overlay shows current sort during drag.
Cache: query key split into prefix (list) for invalidation and full key
(listSorted) for queryOptions. All optimistic update paths use prefix
matching via getQueriesData to work with any active sort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(board): prevent drag flicker by settling columns until mutation refetch
After drag-and-drop, the optimistic cache patch updates position values
without reordering the bucket array. The useEffect that rebuilds columns
from TQ data would overwrite the correct local drag order, causing cards
to snap back then forward. Fix: isSettlingRef blocks column rebuilds
between drag end and mutation onSettled.
Also invalidate issueKeys.list on WS position changes so other windows
refetch correctly sorted data instead of showing stale bucket order.
Includes debug logs (to be removed after verification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(board): stabilize drag-and-drop for non-manual sort modes
Three behavioral fixes for board drag when sort != position:
1. Settling: isSettlingRef + settleVersion blocks column rebuilds
between drag-end and mutation settle, preventing the optimistic
cache patch (which updates position values without reordering the
bucket array) from overwriting the correct local column state.
2. Non-manual cross-column: handleDragOver returns prev (no visual
card movement — column highlight + sort label is sufficient).
handleDragEnd uses overCol directly instead of findColumn on the
card's current position (which would be the source column).
Cards use useSortable({ disabled: { droppable: true } }) to
suppress within-column insertion indicators.
3. Collision detection: when no card droppables exist (disabled in
non-manual sort), return column droppables from pointerWithin
instead of falling through to closestCenter, so isOver reflects
the column the pointer is actually inside.
Also: WS position changes now invalidate issueKeys.list so other
windows refetch correctly sorted data.
Insertion-position prediction intentionally omitted — PostgreSQL's
en_US.utf8 collation (glibc) cannot be faithfully replicated in
JavaScript (ICU/V8), and an inaccurate indicator is worse than none.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sort): manual sort ignores direction param on both ends
Manual sort (position) is user-defined order via drag-and-drop —
reversing it has no product meaning.
Backend: sort=position now skips the direction query param and
always uses ASC. Both ListIssues and ListGroupedIssues handlers.
Frontend: sort object omits sort_direction when sortBy is position.
Direction toggle hidden in the display popover for manual mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(board): memo columns + stabilize references to reduce re-renders
- BoardColumn, PaginatedBoardColumn, PaginatedAssigneeBoardColumn
wrapped in memo() — only columns with changed props re-render
- IssueAgentActivityIndicator wrapped in memo() — 111 snapshot
subscribers no longer trigger full re-render on every WS task event
- buildColumns rewritten from O(groups × issues) to single-pass O(n)
- EMPTY_IDS constant replaces ?? [] fallbacks (stable reference)
- EMPTY_CHILD_PROGRESS constant replaces new Map() default
- BOARD_COL_WIDTH / BOARD_CARD_WIDTH constants shared between
column and DragOverlay for consistent card dimensions
- issueListOptions + issueAssigneeGroupsOptions use
placeholderData: keepPreviousData so sort/filter changes don't
flash a full-page skeleton
- Loading skeleton scoped to content area only — header stays
rendered during data transitions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: remove outdated server-side sort implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600)
The agent resource shape (list / get / create / update / archive /
restore responses + WebSocket events) no longer carries `custom_env`
values. Reads/writes of env now flow exclusively through a dedicated
`/api/agents/{id}/env` endpoint that is owner/admin-only, rejects
agent-actor sessions, applies a "****" sentinel preserve guard on
PUT, and writes a persistent audit row per reveal/update.
Why
- `multica agent list --output json` historically returned plaintext
`custom_env` for owner/admin callers (the redaction gate gave only
members the masked map). Any agent token running on the workspace
inherits its owner's role and could read every other agent's
secrets just by listing.
- Patching list/get redaction alone (PR #3175 direction) left
symmetric leaks via mutation responses, WS events, the "reveal"
path itself (no actor-aware auth), and a `****` overwrite footgun
on UpdateAgent.
What changed
- Backend: drop `custom_env` from AgentResponse; add coarse
`has_custom_env` + `custom_env_key_count`. Strip env handling from
UpdateAgent (silently ignored if sent). Keep CreateAgent's
custom_env acceptance.
- Backend: new GET/PUT `/api/agents/{id}/env` handlers in
`internal/handler/agent_env.go`:
- resolveActor → 403 for agent actors (closes the lateral-movement
path).
- Owner/admin role gate via existing helper.
- PUT honours value == "****" as "preserve existing value".
- Both write to `activity_log` with `agent_env_revealed` /
`agent_env_updated` actions. Audit details record key names only,
never values.
- Daemon claim path (`ClaimAgentTask`) unchanged — `TaskAgentData`
still carries plaintext env for runtime injection.
- SQL: new `UpdateAgentCustomEnv` query; sqlc regenerated (v1.31.1).
- CLI: new `multica agent env get|set` subcommands. `--custom-env*`
flags removed from `multica agent update`; the no-fields error
now points to the new path.
- Frontend: drop env fields from `Agent` + `UpdateAgentRequest`; add
`getAgentEnv` / `updateAgentEnv` client methods; rewrite env-tab
to show "N variables configured" + explicit "Reveal & edit"
button, fetching values only on intentional reveal.
- Locales: parity-safe additions to en + zh-Hans.
- Docs: agents-create.{mdx,zh.mdx} reflect the new threat model and
endpoint.
- Mobile: schema drops `custom_env` / `custom_env_redacted`, adds
metadata fields.
Tests
- Handler tests pinned the new invariants: no env in list/get
responses, owner reveal happy-path + audit row, agent-actor 403,
`****` sentinel preserves real values, UpdateAgent silently
ignores `custom_env`, pure `mergeAgentEnv` cases.
- CLI tests pivot to the new flag surface: `agent update` MUST NOT
expose the env flags; `agent env set` MUST expose
--custom-env-stdin/--custom-env-file.
- Frontend test fixtures updated; pnpm typecheck / test / lint
pass cleanly.
This is a breaking API change. Scripts that read `custom_env` from
`/api/agents` must migrate to `GET /api/agents/{id}/env`.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): close actor-spoofing + audit fail-closed in env endpoints (MUL-2600)
Addresses Elon's review of #3209:
* Mint a task-scoped `mat_` token per claim, bound to (agent, task,
workspace, owner). Daemon injects it into the agent process in place
of its own credential. Auth middleware authoritatively rebuilds
X-User-ID / X-Agent-ID / X-Task-ID from the token row and sets
X-Actor-Source=task_token; that header is server-set only — incoming
values are stripped before any auth branch runs. resolveActor honors
the header so an agent that strips X-Agent-ID / X-Task-ID still
resolves as actor=agent.
* GetAgentEnv / UpdateAgentEnv are now fail-closed on audit-log
failures: GET refuses to return plaintext, PUT persists inside the
same tx as the audit row so they commit/roll back together.
* PUT /api/agents/{id} returns 400 when the body carries custom_env
instead of silently dropping it — directs callers to the audited env
endpoint.
* Agent actors never see mcp_config, even when the underlying member
is owner/admin; mutation broadcasts go through a redaction shim so
WS subscribers don't pick it up either.
* Fix backend test that asserted dense JSON (jsonb::text renders
whitespace) and frontend test that assumed a unique "Test User"
match.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): close residual MUL-2600 gaps from review (MUL-2600)
Migration 108 FK now correctly references agent_task_queue(id) instead
of the non-existent agent_task table; the previous name blocked CI
backend migrations.
Task-token-authenticated requests can no longer be re-routed at a
different workspace by passing workspace_slug / workspace_id /
?workspace_id / a URL workspace param. ResolveWorkspaceIDFromRequest
and resolveWorkspaceUUID both short-circuit on X-Actor-Source=task_token
and return only the token-bound X-Workspace-ID; buildMiddleware adds a
defence-in-depth 403 if any URL-resolved workspace disagrees with the
token binding.
mcp_config no longer leaks back to agent actors through UpdateAgent /
CreateAgent / ArchiveAgent / RestoreAgent HTTP responses — the same
redactAgentResponseForActor helper that GetAgent/ListAgents use is now
applied to mutation responses too. WS broadcasts were already redacted
via broadcastAgentResponse.
FailTask and every TaskService cancel path (CancelTask /
CancelTasksForIssue / CancelTasksForAgent / CancelTasksByTriggerComment
/ BroadcastCancelledTasks) now eagerly DeleteTaskTokensByTask so the
mat_ token's 24h window doesn't outlive a terminated task. Failure is
non-fatal — the FK cascade and expiry remain durable guards.
Doc-only: clarify that PUT /api/agents/{id} now hard-rejects bodies
that carry custom_env (was previously "silently ignores").
Tests:
- middleware: TestResolveWorkspaceIDFromRequest gains a task_token
case asserting client-supplied slug/id/query cannot override the
bound workspace.
- handler: TestUpdateAgent_RedactsMcpConfigForAgentActor and
TestUpdateAgent_KeepsMcpConfigForMemberActor pin the mutation-
response redaction contract per actor type.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): match redacted mcp_config as JSON null, not Go nil (MUL-2600)
`AgentResponse.McpConfig` is `json.RawMessage` without `omitempty`, so
the redacted response serialises as `"mcp_config": null`. On decode,
`json.RawMessage` keeps the literal bytes `null` rather than collapsing
to Go nil, which made the assertion fire on a non-leak.
The product contract (field always present, distinguished from "no
config" via `mcp_config_redacted`) is intentional, so adjust the test
to check for "no secret-bearing content" instead of weakening the
contract via `omitempty`.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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 (, [📎 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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 '.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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).
* 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.
* 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.
* 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.
* 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).
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): use instance_id in deleteCloudRuntimeNode body
Fleet API requires instance_id, not id. Fixes 'instance_id is required' error.
MUL-2510
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): pass node.instance_id instead of node.id to deleteNode mutation
Fleet expects the actual AWS instance_id (e.g. i-0123456789abcdef0),
not the internal DB id. Updated the mutate call in cloud-runtime-dialog
to pass node.instance_id so the correct value reaches Fleet's
DELETE /api/v1/nodes endpoint.
Co-authored-by: multica-agent <github@multica.ai>
* fix: pass node.instance_id and rename param to instanceId
- cloud-runtime-dialog.tsx: deleteNode.mutate(node.instance_id)
- client.ts: rename nodeId param to instanceId
- cloud-runtime.ts: rename nodeId param to instanceId
- client.test.ts: use i-0123456789abcdef0 test value
Co-authored-by: multica-agent <github@multica.ai>
* fix: update test description from 'node id' to 'instance id'
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* 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>
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.
* 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>
* 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
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>
* 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>
## 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`
* 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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
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>
* 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>
* 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>