* 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>
* 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>
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925)
These fields are calendar days (the pickers offer no time-of-day), but were
stored as TIMESTAMPTZ. A client serializing local midnight via toISOString()
folded its timezone into the instant, so the day shifted by the local offset
(GH #3618). Migrate the columns to DATE and parse/serialize date-only
"YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the
UTC day) so older clients keep working.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925)
Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of
toISOString(), and every read formats via the shared @multica/core/issues/date
helpers with timeZone:"UTC" so the day never shifts with the viewer's offset.
The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers,
quick-set menu, list/board/detail/activity, and the mobile due-date picker.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925)
Review follow-ups on #3692:
- ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339
to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight
instant and rejects ambiguous ones loudly. Adds util unit tests.
- migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via
AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too.
- Convert remaining date-change display sites to formatDateOnly: inbox detail
label (web) and mobile activity + inbox labels (were new Date()+local format).
- CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Three independent line-based frontmatter parsers only handled
single-line `description: value`, so a YAML block scalar
(`description: |`) collapsed to the literal "|" and the rest of the
description was dropped before it ever reached the database.
Replace all three with real YAML decoders that understand block
scalars, folded scalars and quoted values:
- server/internal/skill: shared ParseFrontmatter via gopkg.in/yaml.v3,
used by both the handler import path and daemon local-skill discovery
- packages/core/skills: shared parseFrontmatter via the yaml package
- file-viewer renders multi-line frontmatter values (whitespace-pre-wrap)
Both parsers fall back to empty values on malformed YAML, preserving the
previous non-fatal behaviour.
* 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).
* fix(avatar): normalize relative avatar urls in desktop/web
Co-authored-by: multica-agent <github@multica.ai>
* fix: test
Co-authored-by: multica-agent <github@multica.ai>
* fix(avatar): normalize avatar url in AvatarPicker preview
MUL-2746. The picker is used by create-agent and create-squad, and also
prefills from a template's `avatar_url` when duplicating an agent. The
upload result / template URL is root-relative in local-storage setups,
so on Desktop (file:// runtime) the preview <img> resolves against the
local filesystem and the avatar fails to render. Route the value through
`resolvePublicFileUrl` for rendering only; the stored URL stays raw so
the parent's create call still posts what the backend expects.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J (Multica agent) <agents@multica.ai>
* fix(deps): add eslint phantom dep detection + fix existing violations (MUL-2654)
Introduce eslint-plugin-import-x/no-extraneous-dependencies rule to
prevent phantom deps from causing production build splits when pnpm
creates peer-dep variants. Fix all existing phantom deps across the
monorepo, unify catalog references, and enable desktop smoke CI on PRs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* revert(ci): remove desktop smoke PR trigger per user feedback
The existing smoke workflow only verifies packaging completes — it does
not actually start the app or check rendering. This means it wouldn't
have caught the white-screen bug (which was a runtime issue, not a build
failure). Adding it to PRs would slow CI without providing meaningful
protection. The ESLint no-extraneous-dependencies rule is the actual
prevention mechanism.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(deps): sync pnpm-lock.yaml for rehype-sanitize dep classification
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): move rehype-sanitize to deps + declare eslint-config (MUL-2654)
- Move rehype-sanitize from devDependencies to dependencies (used in
production Markdown.tsx)
- Add @multica/eslint-config to devDependencies (imported by
eslint.config.mjs but previously undeclared)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix: sort timeline entries by created_at on WebSocket append
When multiple agents post comments concurrently, WebSocket events may
arrive out of chronological order. The handlers blindly appended new
entries to the end of the cached timeline array, causing display
misordering. This fix sorts the array by created_at (with id as
tie-breaker) after each insert.
Changes:
- use-issue-timeline.ts: sort after comment:created and activity:created
- issue-ws-updaters.ts: sort in appendTimelineEntry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(views): extract sortTimelineEntriesAsc helper, cover mutation onSuccess
Review feedback from @Bohan-J: useCreateComment.onSuccess also appends
unsorted (mutations.ts:558). When the local user posts a comment whose
HTTP response returns after a concurrent WS event, the unsorted append
leaves the cache misordered and the subsequent WS dedup skips re-sort.
Extract sortTimelineEntriesAsc helper and reuse it in all three web
cache writers:
- comment:created WS handler
- activity:created WS handler
- useCreateComment.onSuccess
Mobile keeps its own inline sort (apps/mobile/CLAUDE.md boundary).
Add regression tests for sort position (mid-insert and oldest-insert).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* 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>
* feat(issues): surface "agent working" on board + add Working filter (MUL-2452)
Adds a brand-color "agent working" badge to board cards / list rows so
users can see at a glance which issues have an active agent task, plus a
new "Working" toggle on the `/issues` and `/my-issues` headers (next to
the existing scope segmented control) that filters to those issues. The
toggle shows an avatar stack of the agents currently active on the
current surface + scope. Pure frontend: re-shapes the existing
workspace-wide `agentTaskSnapshot` cache via two new selectors
(`activeTasksByIssueOptions` / `workingIssueIdsOptions`), no new SQL,
endpoint, or DB field; WS `task:*` events already invalidate the
snapshot so the badge / filter update in realtime.
Project detail page keeps the per-card badge but intentionally omits the
header toggle (`showWorkingToggle={false}`) to leave the project
surface's filter dimensions unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): working filter column header reflects filtered count (MUL-2452)
Assignee-grouped board column headers kept showing the unfiltered cache
total when Working was on, because `PaginatedAssigneeBoardColumn` passed
`useLoadMoreByAssigneeGroup`'s cache-derived `total` straight to
`BoardColumn`. The hook still needs the cache total for hasMore, but the
displayed count must follow the visible-after-filter set.
Split the two: when Working is active the column header now uses
`group.totalCount` (set by applyWorkingFilterToGroups) for the assignee
path, and `issueIds.length` for the status path. Load-more keeps reading
from cache so paginated columns still see the full server total.
Regression tests cover applyWorkingFilterToGroups (total rewrite +
empty-group preservation), filterIssues workingOnly combinations, and an
end-to-end assertion via IssuesPage that proves the column header equals
the filtered count, not the cached value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* fix(realtime): invalidate workspace queries on WSClient instance change
When switching workspaces, the old WSClient is torn down and a new one
is created. Events emitted during the transition are lost because
onReconnect only fires for reconnections within the same instance.
Add an effect that tracks the WSClient instance via useRef and, on
detecting a non-initial new instance, invalidates all workspace-scoped
queries (same set as onReconnect). The first assignment is skipped to
avoid redundant refetches on initial mount.
Closes multica-ai/multica#2562
Co-authored-by: multica-agent <github@multica.ai>
* refactor(realtime): extract shared invalidation helper + add ws instance test
- Extract invalidateWorkspaceScopedQueries() to deduplicate the
invalidation key list shared by onReconnect and ws-instance-change effects
- Add hook test covering: first ws skip, null gap no-op, new instance
invalidates exactly once, same instance no re-invalidation
Addresses review nits from PR #2665.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-2216: feat(agents,squads): persist Mine/All tab selection per workspace
Tab selection on the Agents and Squads list pages was held in
component-local state, so navigating into a detail page and back
remounted the list and reset the tab to the default "Mine". Move
`scope` into Zustand stores backed by `persist` +
`createWorkspaceAwareStorage`, matching the pattern used by the
Issues view store. Selection now survives list → detail → back
navigation and page reloads, scoped per workspace.
Only `scope` is persisted; `search`, `sort`, and other ephemeral
filters intentionally still reset on remount.
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): reset scope to mine when switching to a workspace with no persisted value
zustand persist.rehydrate() is a no-op when storage returns null, so
workspaces with no entry kept the previous workspace's in-memory scope
("all" leaked from one workspace into the next). Provide a custom merge
that resets to the default "mine" when no persisted state is present.
Add coverage for the missing-storage workspace-switch case for both
Agents and Squads.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(dashboard): workspace/project token + run-time dashboard
Add a `/{slug}/dashboard` page showing per-agent token spend and execution
time across the whole workspace, with an optional project filter.
Backend:
- Three new sqlc queries against task_usage + agent_task_queue: daily
usage, per-agent usage, per-agent total run-time. All optionally
scoped to a project via sqlc.narg('project_id'), reaching project
through the issue join.
- Handlers under /api/dashboard return the same wire shape the runtime
page already consumes (model preserved for client-side cost math).
Frontend: - Shared DashboardPage in packages/views/dashboard reusing KpiCard,
DailyCostChart, ActorAvatar, and estimateCost from the runtime page
so the visual style and pricing math stay in lock-step.
- Period selector (7/30/90d), project dropdown, four KPI tiles
(cost, tokens, run time, tasks), daily cost chart, and a combined
"cost + run time by agent" list.
- Routed in both web (app/[slug]/(dashboard)/dashboard) and desktop
(memory router); sidebar nav entry added under Workspace group.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard): drop stale project filter and stop double-counting tasks
Two issues caught in PR #2462 review:
1. Project filter held the previous selection's UUID across workspace
switches and project deletions: the dropdown gracefully showed
"All projects" (because the title lookup missed) while the three
dashboard queries kept forwarding the dead UUID, leaving the UI
looking like a full-workspace view but populated with empty
project-scoped data. Validate the picked UUID against the current
projects list before passing it to the queries.
2. The "by agent" table read its task count from the token rollup,
which is grouped per (agent, model). A single task that spans two
models lands twice and the agent's row reads e.g. "2 tasks" when
the real count is 1. Prefer `ListDashboardAgentRunTime`'s per-agent
distinct count when available; fall back to the token aggregate
only for agents with no terminal run yet (in-flight tasks).
Extract the merge into `mergeAgentDashboardRows` so the precedence
rules are unit-tested directly.
Co-authored-by: multica-agent <github@multica.ai>
* test(dashboard): allocate per-workspace issue.number explicitly
TestDashboardEndpoints creates two issues in the shared fixture
workspace. issue.number defaults to 0 (migration 020), and the table
carries UNIQUE (workspace_id, number), so the second insert raced the
first on the same default and failed in CI.
Allocate MAX(number) + 1 per insert so each row gets a fresh number
without stepping on rows other tests left behind in the same workspace.
Co-authored-by: multica-agent <github@multica.ai>
* feat(dashboard): rollup table + cron-driven aggregation for dashboard
Mirror the per-runtime rollup in `task_usage_daily` (migrations 073/077/082)
to remove the per-request raw aggregation the dashboard was doing.
Migration 084 adds:
- `task_usage_dashboard_daily` keyed on
(bucket_date, workspace_id, agent_id, project_id, model) — the
dimensions the dashboard actually queries, with project_id nullable
via UNIQUE NULLS NOT DISTINCT (PG15+) so "no-project" buckets
upsert cleanly.
- `task_usage_dashboard_rollup_state` watermark table.
- `task_usage_dashboard_dirty` invalidation queue.
- Triggers on agent_task_queue DELETE, task_usage DELETE, and
issue.project_id UPDATE — the cases the updated_at watermark can't
see. The project_id trigger re-attributes existing rollup rows when
a user moves an issue across projects.
- `rollup_task_usage_dashboard_daily_window(from, to)` —
idempotent recompute primitive (same shape as 077).
- `rollup_task_usage_dashboard_daily()` cron entry — own advisory
lock (4244) so it serialises independently of the runtime rollup.
- `task_usage_dashboard_rollup_lag_seconds()` health helper.
Sqlc queries `ListDashboardUsageDailyRollup` /
`ListDashboardUsageByAgentRollup` read from the new table; the handler
dispatches between rollup and raw on a separate
`UseDailyRollupForDashboard` config flag
(`USAGE_DASHBOARD_ROLLUP_ENABLED` env). Same fail-safe default (false →
raw) so operators can roll out independently of the per-runtime flag.
Bucket date is UTC (the dashboard aggregates across runtimes that may
sit in different tzs; there's no single correct local boundary).
Adds `cmd/backfill_task_usage_dashboard_daily` mirroring the existing
per-runtime backfill — operator runs it once before flipping the flag.
Tests: - TestDashboardEndpoints now also exercises the rollup read path
(raw vs. rollup, same project-scoped totals).
- TestDashboardRollupReattributesOnProjectChange verifies the
issue.project_id trigger enqueues both old + new buckets and the
next rollup tick zeroes the old project + populates the new one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard-rollup): close two invalidation gaps
Two leak paths missed by migration 084 review:
1. Issue cascade DELETE — the atq BEFORE DELETE trigger runs AFTER the
issue row is gone, so `LEFT JOIN issue` returns NULL project_id and
the original-project bucket never gets cleared (issue 077 calls this
out for the runtime rollup but didn't need to act on it). Adds an
`issue BEFORE DELETE` trigger that enqueues using OLD.project_id
while the issue row is still readable.
2. `LinkTaskToIssue` (quick-create task attaching to a real issue post-
completion) UPDATEs `agent_task_queue.issue_id` from NULL to a real
id. Migration 084 only watched DELETE on atq, so usage already
rolled up under the no-project bucket stayed attributed to NULL
forever. Extends the atq trigger to fire on UPDATE OF issue_id too,
enqueueing both OLD (NULL project) and NEW (linked issue's project).
Tests: - TestDashboardRollupClearsOnIssueDelete asserts rollup row drops to
zero after issue delete + rollup tick.
- TestDashboardRollupReattributesOnLinkTaskToIssue verifies tokens
move from the NULL bucket to the project bucket after the UPDATE.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(github): GitHub App backend for PR ↔ issue linking
- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.
Co-authored-by: multica-agent <github@multica.ai>
* feat(github): UI for connecting GitHub and viewing linked PRs
- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast
- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): i18n the new GitHub UI strings to satisfy lint
CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(runtimes): let users set custom prices for unmaintained models
The Runtime > Usage pricing diagnostic previously told users to "edit
packages/views/runtimes/utils.ts" when a model wasn't priced. That's
fine for us, useless for everyone else. We can't track every model
release, so let users supply their own per-million-token rates for
anything we don't ship a maintained rate for (e.g. gpt-5.5-mini today).
- Add a persisted Zustand store (custom-pricing-store) keyed by model
name; rates live in localStorage so they survive reloads.
- resolvePricing consults the maintained MODEL_PRICING catalog first,
then falls back to the store. Catalog still wins on overlap so a
stale local override can't shadow a known rate.
- EmptyChartState gains a "Set custom prices" button when unmapped
models exist; the dialog lists every unmapped model plus everything
already overridden so users can edit / clear prior entries.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): show pricing-gap notice for partial unmapping; invalidate cost memos on price save
Two bugs surfaced in review:
1. The "Set custom prices" CTA only showed inside EmptyChartState, which
only fires when Daily / Hourly total cost is exactly 0. Mixed windows
(some priced + some unpriced models) rendered the chart normally and
left no entry point — the unpriced tokens silently contributed \$0
to totals.
Add a permanent UnmappedPricingNotice above the KPI grid that appears
whenever collectUnmappedModels(filtered) is non-empty, regardless of
chart state. EmptyChartState keeps the diagnostic text but the CTA
button moves to the notice so the two surfaces don't duplicate.
2. The aggregate useMemo blocks (WhenChart's dailyCostStack / hourlyCost,
CostByBlock's byAgent / byModel, ActivityHeatmap's cells) keyed only
on their query data. After a price save the parent re-rendered, but
the memos returned cached pre-save totals because their deps were
identical. The KPI cards updated; the charts did not.
Subscribe to the pricing store in each aggregating component and
list `pricings` as a memo dependency. The store returns a stable
reference until setCustomPricing fires, so memos only invalidate
on real changes.
New unit tests cover both: a mixed priced/unpriced aggregate produces
mixed costs (and surfaces the unpriced names), and aggregateCostByModel
called twice on the same input array reflects a freshly-saved override.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* refactor(timeline): drop server-side comment + timeline pagination (MUL-1929)
The cursor-paginated /timeline and /comments endpoints were sized for a
problem the data shape doesn't have: prod p99 is ~30 comments per issue
and the all-time max is ~1.1k. Time-based pagination also splits reply
threads across page boundaries (orphan replies), which the frontend was
papering over with an "orphan rescue" that promoted disconnected replies
to top-level — confusing UX with no real benefit.
Replace both endpoints with a single full-issue fetch, capped server-side
at 2000 rows as a defensive safety net (never hit in practice).
Server
- /api/issues/:id/timeline now returns a flat ASC TimelineEntry[]
(matches the legacy desktop contract — older Multica.app builds keep
working because the wrapped TimelineResponse + cursors are gone, and
the raw array shape was always what they consumed).
- /api/issues/:id/comments drops limit/offset; only ?since is honoured
for the CLI agent-polling flow.
- Drop ListCommentsBefore/After/Latest, ListActivitiesBefore/After/Latest
and the timelineCursor encoding.
- Replace with ListCommentsForIssue / ListCommentsSinceForIssue /
ListActivitiesForIssue (capped by argument).
CLI
- multica issue comment list drops --limit / --offset and the X-Total-Count
reporting; --since is preserved for incremental polling.
Frontend
- Replace useInfiniteQuery with useQuery in useIssueTimeline; drop
fetchOlder/Newer, jumpToLatest, isAtLatest, newEntriesBelowCount.
- Remove timeline-cache helpers (mapAllEntries / filterAllEntries /
prependToLatestPage) and the TimelinePage / TimelinePageParam types.
- WS event handlers update the single flat-array cache directly.
- Drop the orphan-reply rescue in issue-detail — every reply's parent
is now guaranteed to be in the same array.
- Strip the "show older / show newer / jump to latest" buttons and their
i18n strings.
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): address review feedback on pagination removal
Three issues caught in PR #2322 review:
1. /timeline broke for stale clients between #2128 and this PR. They send
?limit/?before/?after/?around and parse with the wrapped TimelinePageSchema;
the new flat-array response was failing schema validation and falling back
to an empty timeline. Restore the wrapped shape on those query params
(DESC entries, null cursors, has_more_*=false), keeping the flat ASC array
for bare requests. Around-mode now also fills target_index from the merged
slice so legacy clients can still scroll-to-anchor without a follow-up.
2. The agent prompts in runtime_config.go and prompt.go still told agents
that `multica issue comment list` accepts --limit/--offset and to use
`--limit 30` on truncated output. With those flags removed in this PR,
new agent runs would hit "unknown flag" or skip context. Update the
prompt copy to "returns all comments, capped at 2000; --since for
incremental polling".
3. useCreateComment's onSuccess was a bare append to the timeline cache
with no id-dedupe, so a fast comment:created WS event firing before
onSuccess produced a transient duplicate. Restore the id guard the old
prependToLatestPage helper used to provide.
Adds two new boundary tests:
- TestListTimeline_LegacyWrappedShape_OnPaginationParams
- TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): fix timeline test assertions for handler-package isolation
The TestListTimeline_* assertions assumed CreateIssue would seed an
"issue_created" activity_log row, but the activity listener that publishes
those rows is registered in cmd/server/main.go — handler-package tests
don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the
test expected ≥6.
Drop the auto-activity assumption: assert exactly 5 entries in
TestListTimeline_MergesCommentsAndActivities, and tighten
TestListTimeline_EmptyIssue to assert a fully-empty timeline.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* docs(claude): add API Response Compatibility section
Narrows the existing "no backwards compat" rule to internal code only,
and adds a new section that codifies the defensive boundary at API
edges: parse-don't-cast, never pin UI to a single field, enum drift
must downgrade not crash.
Driven by #2143/#2147/#2192 — all three were the desktop client white-
screening on backend response shape changes the client wasn't built
against.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(core): add zod-based API response validation layer
Introduces a defensive boundary so a malformed backend response
degrades into a safe fallback (empty page, [], etc.) instead of
throwing inside React render.
- Adds zod to the pnpm catalog and as a @multica/core dependency.
- New parseWithFallback helper in core/api/schema.ts that runs
safeParse, logs a warn with the endpoint + zod issues on failure,
and returns the caller-supplied fallback. Never throws.
- Schemas in core/api/schemas.ts are deliberately lenient (string
enums kept as z.string() so unknown values still parse, optional
fields default, nested records use .loose() for unknown keys).
- Wires setSchemaLogger from CoreProvider so warnings flow through
the same logger as the rest of the API client.
This is the primitive — see the next commit for the call-site wiring.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(api): guard top 5 high-risk endpoints with parseWithFallback
Wraps the response of the five endpoints whose UIs white-screened in
past incidents (#2143/#2147/#2192) so a contract drift returns a safe
fallback instead of crashing the consumer:
- listIssues → ListIssuesResponseSchema, fallback { issues: [], total: 0 }
- listTimeline → TimelinePageSchema, fallback empty page
- listComments → CommentsListSchema, fallback []
- listIssueSubscribers → SubscribersListSchema, fallback []
- listChildIssues → ChildIssuesResponseSchema, fallback { issues: [] }
getIssue is intentionally NOT wrapped: there is no sensible "empty
issue" — the entire detail page depends on real fields. The page-level
ErrorBoundary (separate commit) catches that case.
Adds schema.test.ts with 9 cases covering the five failure modes
listed in MUL-1828: missing fields, wrong types, enum drift, null
body, and null arrays.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(ui): add ErrorBoundary and wrap high-risk pages
Section-level error boundary (no third-party dep — class component +
default fallback in @multica/ui). Supports a fallback render prop and
resetKeys for auto-recovery on resource navigation.
Wraps the surfaces that white-screened in past incidents:
- IssueDetail (web + desktop + inbox split-pane) — keyed on issueId
so navigating to a different issue clears the boundary automatically.
- IssuesPage (web + desktop).
Boundaries are placed at consumer call sites rather than inside
IssueDetail itself so we don't have to refactor the 1100-line
component, and so a crash inside one inbox split-pane doesn't take
down the inbox list next to it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(core): make all API schemas .loose() to preserve unknown fields
zod 4 z.object() defaults to STRIP, which silently drops fields the
schema didn't list. That makes the schema layer a sync point: a future
PR adding a TS field but forgetting the schema would have the field
disappear at runtime while TS still claims it exists — the exact bug-
class this PR is meant to prevent, just inverted.
Apply .loose() to every object schema (TimelineEntry, TimelinePage,
Comment, Issue, ListIssuesResponse, Subscriber, ChildIssuesResponse)
so unknown server-side fields pass through unchanged. Add a regression
test that feeds a payload with extra fields at both entry and page
level, and a direct unit test for parseWithFallback decoupled from any
endpoint. Update the listIssues fallback test to use a wrong-type
payload — under .loose() the previous "{ unexpected: true }" payload
parses successfully (every declared field has a default) instead of
triggering the fallback path it was meant to exercise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(claude): strip field-specific examples from API Compatibility section
The original wording embedded current schema field names (entries,
has_more_before, has_more_after, cursor, status, type) directly in the
rules. CLAUDE.md should state the rule, not the implementation — once a
field is renamed the doc drifts out of sync with the code, and the
specific names don't add anything the abstract rule doesn't.
Keep the rule, drop the field-level archaeology.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)
Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.
This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.
API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
+ ?limit (default 50, max 100); response wraps entries with next/prev
cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
every row unbounded; the unbounded ListComments / ListActivities sqlc
queries are deleted.
Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
prepends to pages[0] only when the user is reading the live tail;
otherwise it just bumps a counter so the UI offers a "Jump to latest"
affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
core/issues/timeline-cache.ts) and use setQueriesData so all open
windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
during the brief load window plus subtle text-link load-more buttons
matching the existing Subscribe affordance (no Button chrome). Top
uses a divider for boundary clarity; bottom shows
"Jump to latest · N new" weighted slightly heavier when there's
unread state.
- highlightCommentId now flows into the hook's around parameter so
Inbox jumps fetch the surrounding 50 entries directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(agent): default comment list to 50 + prompt hint about long issues
The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.
The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.
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(i18n): rollout phase — translate 9 namespaces (WIP)
Phase 1 complete (基建 + login + Settings language switcher),
phase 2 partial (Wave 4 done, search done). Pending namespaces
documented inline; another developer can pick up from here.
Infrastructure
--------------
- server: add users.language column + extend PATCH /api/me
(TestUpdateMeAcceptsLanguage / TestUpdateMePreservesLanguage)
- packages/core/i18n: types / pickLocale (intl-localematcher) /
browser-cookie-adapter / createI18n (initAsync false +
useSuspense false) / I18nProvider / LocaleAdapterProvider
- Split server-safe vs React entries:
@multica/core/i18n — for proxy/RSC/middleware (no React)
@multica/core/i18n/react — for client trees (createContext)
(RSC vendored React lacks createContext; mixed import would crash
proxy.ts at module load.)
- packages/views/i18n: useT hook + selector API augmentation
(i18next v26 default; auto-propagates to apps via the side-effect
import in use-t.ts).
- apps/web: proxy.ts (Next 16 renamed middleware) merges existing
legacy/root redirects with x-multica-locale header forwarding;
layout.tsx reads locale via headers() and pre-loads RSC resources.
- apps/desktop: webPreferences.additionalArguments injects
systemLocale (no sendSync — avoids main-thread blocking IPC);
renderer adapter reads via process.argv.
- ESLint: i18next/no-literal-string at file-scope for translated
files via packages/views/eslint.config.mjs TRANSLATED_FILES.
- glossary.md (packages/views/locales/) freezes term policy:
Issue / Workspace / Agent / Skill / Autopilot / Daemon / Runtime
stay English; Inbox / Project / Comment / Member translate.
Translated namespaces (9 / 19)
------------------------------
- auth: login page (web wrapper含 desktop-handoff 文案) + Settings
Appearance language switcher
- editor: 9 .tsx (bubble-menu / link-hover-card / readonly-content /
title-editor / extensions: code-block / file-card / image-view /
mention-suggestion) + 32 keys
- invite: 25 keys
- labels / members / my-issues: Wave 4 全部
- search: command palette 35 keys
- navigation: no user-facing strings (no-op)
Pending (10 / 19)
-----------------
issues (46 files / ~210 keys)
agents (29 files / ~155 keys; presence.ts + config.ts label maps
允许进 i18n)
onboarding (22 files / ~150 keys)
settings rest / skills / modals / workspace / chat / inbox /
projects / autopilots / layout
Workflow for picking up
-----------------------
- Glossary: packages/views/locales/glossary.md (mandatory read)
- Reference impls: auth/login-page.tsx + editor/* (selector API +
i18n-provider test wrapper pattern)
- Per namespace:
1. create locales/{en,zh-Hans}/{ns}.json
2. add to packages/views/i18n/resources-types.ts
3. useT('{ns}') + t($ => $.foo) in components
4. add files to TRANSLATED_FILES in eslint.config.mjs
5. typecheck + test + lint must pass
- Subagents currently CANNOT write files (sandbox deny). Run as
hybrid: subagent researches + outputs full JSON + tsx diff,
controller writes.
Other
-----
- scripts/init-worktree-env.sh: default
MULTICA_DEV_VERIFICATION_CODE=888888 in dev for deterministic
login (gated by isProductionEnv).
Verified: pnpm typecheck (6 pkgs ok), pnpm test (232 pass),
make test (Go).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(i18n): rewrite glossary aligned with docs zh voice
Switch translation policy to match the canonical CN voice already
established in apps/docs/content/docs/*.zh.mdx (20+ files). The new
rule splits product nouns into two classes:
- Typed entities (issue / project / skill / autopilot / task) — kept as
lowercase English in CN text, visually marking them as system types.
- Concepts (workspace / agent / daemon / runtime / inbox) — fully
translated (工作区 / 智能体 / 守护进程 / 运行时 / 收件箱).
Previous glossary kept Workspace / Agent / Daemon / Runtime as English
on "工程惯例" grounds, but docs zh and CN AI ecosystem (Coze / 腾讯元器
/ 百度) consistently translate these. App UI now matches docs voice so
users don't see split personality between the app and its own docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): register 6 namespaces and retrofit zh strings to new glossary
Two fixes that were blocking the previously-translated namespaces from
actually rendering in CN:
1. RESOURCES gap — locales/index.ts only loaded common/auth/settings,
but resources-types.ts declared 12 namespaces and 6 of them had real
translation content. At runtime i18next would fall back to raw keys
for editor / invite / labels / members / my-issues / search.
Register all 9 currently-translated namespaces.
2. Retrofit zh strings to the docs-aligned glossary:
- "Issue" → "issue" (lowercase entity)
- "Workspace" → "工作区"
- "Agent" → "智能体"
- "Runtime" → "运行时"
- "Skill" → "skill" (lowercase)
- "项目" → "project" (lowercase)
Touched: editor.json (sub_issue + mention.group_issues), invite.json
(3 Workspace occurrences), members.json (agents_section / more_agents),
my-issues.json (8 retrofits across page/header/errors), search.json
(13 retrofits across groups/pages/commands/empty).
Verified: pnpm typecheck (6/6) + pnpm test (238/238) all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate inbox namespace
First namespace through the sub-agent → main-agent integration pipeline.
JSON: en/inbox.json + zh-Hans/inbox.json — 60 keys across page / menu /
list / detail / types / labels / errors. Time-formatter labels are kept
compact in EN ("5m" / "3h" / "2d") and use full units in zh ("5 分钟" /
"5 小时" / "5 天") since raw "5 分" reads as "5 marks/points" in CN.
Component changes converted two module-level statics into hooks so the
strings can flow through i18next:
- inbox-list-item.tsx: `timeAgo` (pure fn) → `useTimeAgo` (hook
returning a fn). The local copy is a duplicate of @multica/core/utils
`timeAgo` that is only used by inbox-page; other consumers across
chat/agents/skills/issues stay on the core util for now and will be
translated when their namespaces land.
- inbox-detail-label.tsx: `typeLabels` (static const Record) →
`useTypeLabels` (hook returning the same Record shape). Call sites
keep the existing `typeLabels[type]` access pattern.
inbox-page.tsx now uses both hooks and `useT('inbox')` selector calls
for all hardcoded strings (~24 sites: header / dropdown menu / list
empty state / detail panel / mobile back / quick-create-failed flow /
all error toasts).
Wired up: resources-types.ts, locales/index.ts RESOURCES, ESLint
TRANSLATED_FILES (3 inbox tsx files now lint-protected).
Verified: pnpm typecheck (6/6) + pnpm --filter @multica/views test
(238/238) + ESLint clean on inbox/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate workspace namespace
Translates the three workspace shell views: create-workspace-form,
new-workspace-page, no-access-page. Also fixes the prior-art
no-unescaped-entities lint errors in no-access-page.tsx — the
apostrophes in "doesn't" / "don't" were JSX text literals that move
into JSON values after translation, so the lint rule no longer fires.
Tests wrapped: workspace/create-workspace-form.test.tsx,
workspace/no-access-page.test.tsx, modals/create-workspace.test.tsx
all now wrap render() with <I18nProvider locale="en"> so the en values
in workspace.json drive the rendered text and the existing assertions
continue to match.
Slug constants kept: WORKSPACE_SLUG_FORMAT_ERROR /
WORKSPACE_SLUG_CONFLICT_ERROR exports in workspace/slug.ts are still
imported by onboarding/steps/step-workspace.tsx (out of scope here).
The workspace shell now reads its strings from workspace.json directly.
Multica.ai brand prefix in the slug input affordance is wrapped with
an inline `// eslint-disable-next-line i18next/no-literal-string` per
glossary policy on brand names.
Renamed sign_in_other → sign_in_different to avoid colliding with
i18next's `_other` plural-suffix convention which the selector-API
typings treated as a plural form of `sign_in`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate projects namespace
Translates the projects list page, project detail page, project picker
dropdown, and project chip — all four user-facing surfaces under
packages/views/projects/components/.
New file: projects/components/labels.ts exposes three hooks that
replace the static `.label` field on PROJECT_STATUS_CONFIG /
PROJECT_PRIORITY_CONFIG and the previous module-level
`formatRelativeDate` helper. Core's `.label` stays untouched (it's
still consumed by search and the create-project modal, both
out-of-scope for this namespace) — those will flip when their
respective namespaces translate.
In zh, the "project" entity stays lowercase English per glossary
(`新建 project`, `还没有 project`, `从 project 移除`). Status / priority /
table column labels translate fully.
The cancelled / done / paused etc. status labels duplicate per-
namespace as `projects.status.*` rather than reading from a future
shared status namespace. This matches the auth/inbox/workspace
pattern of self-contained namespaces. If a generic "issue/project
status" pool emerges later, these can collapse.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on projects/ (1 pre-existing warning
about useEffect/sidebarRef dep, unrelated to i18n).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate autopilots namespace
Six tsx files: autopilots-page (list + 6 templates), autopilot-detail-page
(properties / triggers / run history / delete), autopilot-dialog
(create + edit dialog), trigger-config (cron form), and the agent /
timezone pickers.
Hook conversions for module-level helpers that need t():
- summarizeTrigger / describeTrigger → useSummarizeTrigger /
useDescribeTrigger (no external callers, removed the plain exports)
- formatRelativeDate → useFormatRelativeDate (per-component hook)
- formatCountdown → useFormatCountdown (per-component hook)
- TEMPLATES array now keyed by id; titles + summaries pull from
templates/{id}/{title,summary} JSON. Prompts stay raw EN since
they're injected directly into the agent task — translating them
would translate the agent's instructions, not the user's UI.
Status / execution-mode / run-status enums render via t($ => $.status[k])
with k typed against the core type (no separate hook needed).
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on autopilots/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate skills namespace
Seven tsx files: skills-page (list + filters + intro banner),
skill-detail-page (the giant — properties + file tree + sidebar +
conflict banner + delete dialog, ~963 lines), create-skill-dialog
(chooser + manual + URL forms), runtime-local-skill-import-panel
(local runtime browse + import), skill-columns, file-tree, file-viewer.
Notable patterns:
- `createSkillColumns` factory → `useSkillColumns` hook so column
headers flow through useT. Column identity changes per render is
fine — DataTable handles it.
- `validateNewFilePath` (pure helper) → `useValidateNewFilePath` hook
so the 5 validation error messages can be translated.
- skill_files / used_by / description_with_agents use i18next plural
keys (`_one` / `_other`) — the type system collapses these into a
single PluralValue access, so call sites use
`t($ => $.foo, { count })` and i18next picks the form.
- Per glossary, "skill" stays lowercase EN in zh ("新建 skill",
"已删除 skill", "未找到该 skill").
Test wrapper: runtime-local-skill-import-panel.test.tsx now wraps
render() with <I18nProvider> so the assertion on /Import to Workspace/i
matches the EN translation.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on skills/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate chat namespace
Translates all 10 chat surfaces: FAB tooltip, input placeholders,
message list (replied-in / failed-after / tools group / show-details
/ tool result preview), session history (header + time-ago labels),
chat window (new-chat / restore / expand / minimize / agent + session
dropdowns / starter prompts / empty states), context-anchor button +
card tooltips, no-agent banner, offline / unstable banner, and the
task-status pill (queued / starting up / thinking / typing + tool
labels: running command / reading files / searching code / making
edits / searching web).
Hook conversions:
- formatTimeAgo (chat-session-history) → useFormatTimeAgo
- ElapsedCaption now takes a typed `variant` ("replied" | "failed")
instead of a free-text `verb` so the i18n key is enumerable
- pickStage (task-status-pill) refactored: pure pickStageKeys returns
StageKey + optional ToolKey; useResolveStage maps to localized labels
Translation policy notes:
- Starter prompts ("List my open tasks by priority", etc.) are user
UI when displayed AND the user's input when clicked — translating
them sends the agent the user's locale-native phrasing, which is
the right UX for a CN user using a CN agent.
- buildAnchorMarkdown (chat-window) stays in English: it's an
agent-bound markdown prefix injected into the outgoing message,
not user-facing UI.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate modals namespace
Translates all 11 modal sources: registry (no UI text), backlog-agent-hint,
set-parent-issue, add-child-issue, delete-issue-confirm, feedback,
issue-picker, create-workspace, create-project, create-issue (manual),
quick-create-issue (agent panel).
Notable patterns:
- create-project re-uses useProjectStatusLabels / useProjectPriorityLabels
hooks from views/projects/components/labels — same translation source
as the projects list / detail, no duplication.
- create-issue.tsx: renamed `toast.custom((t) => ...)` callback param to
`toastId` to avoid shadowing the closure-captured useT() `t` function.
- Test wrapper added to modals/create-issue.test.tsx so the two assertions
on rendered modal text (success toast + Create another) match the EN
bundle. modals/create-workspace.test.tsx was already wrapped (workspace
ns commit).
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate settings namespace (rest of tabs)
Builds on the appearance-tab + language switcher already shipped in
Phase 0. Translates the remaining 8 settings surfaces: settings-page
shell (left nav + tab keys), account / profile, notifications-tab
(5 group labels + descriptions), tokens-tab (create / list /
revoke / created dialog), workspace-tab (general fields + danger
zone + leave/delete confirmations), members-tab (invite + role
config + revoke / remove flows), repositories-tab, labs-tab,
delete-workspace-dialog.
Hook conversion: members-tab `roleConfig` static const → `useRoleLabels`
hook returning a Record<MemberRole, {label, description, icon}>. The
icon stays as a typed React component (Crown / Shield / User), so
rendering pattern is unchanged at call sites.
Test wrapper: settings/components/delete-workspace-dialog.test.tsx
now wraps render() with <I18nProvider> (custom render() helper)
because the test asserts on rendered button labels ("Delete workspace",
"Cancel", "Deleting...").
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate runtimes namespace (entry surfaces)
Translates the user-facing runtime list page surfaces:
runtimes-page (header / search / filters / chips / empty / no-matches /
bootstrapping), runtime-detail (topbar + delete dialog + delete toasts),
runtime-detail-page (not-found state), shared.tsx (4-state HealthBadge
labels).
Hook conversion: shared `healthLabel(health)` was a pure module-level
function. Added `useHealthLabel` hook for translated call sites; kept
`healthLabel` as an EN-only fallback for non-component callers (column
factory in runtime-columns).
Deferred:
- runtime-list / runtime-columns (data table column headers + cell
bodies) — large surface, not in the page-load critical path.
- connect-remote-dialog / update-section / usage-section — secondary
flows, English remains acceptable until a focused pass.
- charts/* — primarily numeric tooltips and axes; minimal user-visible
text.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate layout namespace (sidebar nav, help, loader)
Translates the cross-cutting layout chrome:
- 9 sidebar nav labels (inbox / my issues / issues / projects /
autopilots / agents / runtimes / skills / settings) — driven by
labelKey instead of inline strings, resolved via useT at render.
- HelpLauncher dropdown (trigger aria + 3 items: Docs / Change log
/ Feedback)
- WorkspaceLoader (named + unnamed loading states)
- SortablePinItem unpin tooltip
Pattern shift in app-sidebar.tsx: nav arrays carry `labelKey: NavLabelKey`
(typed against the layout JSON) instead of `label: string`. The string
comparison checks (`item.label === "Inbox"`) became cleaner ID-based
checks (`item.key === "inbox"`).
Deferred: deeper sidebar surfaces — workspace switcher dropdown,
"New Issue" CTA, "Pinned" / "Workspace" / "Configure" group labels —
remain English. The 9 nav labels are the ones that read in every
session.
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate onboarding namespace (welcome + step header)
Translates the user-first-impression surfaces of the onboarding flow:
- step-welcome.tsx (the wordmark, headline, lede paragraphs, all CTAs:
Download Desktop / Continue on web / Start exploring / I've done
this before, illustration caption)
- step-header.tsx ("Step N of M" counter + matching aria-label)
- onboarding-flow.tsx (skip-onboarding error toast)
Test wrapper added to onboarding/components/step-header.test.tsx —
custom render() helper wraps with <I18nProvider> so the "Step 2 of 5"
assertions match the EN bundle.
Deferred (acceptable English fallback for now): step-questionnaire,
step-workspace, step-runtime-connect, step-platform-fork, step-agent,
step-first-issue, cli-install-instructions, option-card, runtime
aside panels, starter-content-prompt, cloud-waitlist-expand. These
are deeper steps with significant copy that would benefit from a
focused dedicated pass — voice on each is more nuanced (questionnaire
options, runtime install instructions, agent template recommendations).
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(i18n): add EN/zh-Hans key parity guard
Schema-level vitest that walks RESOURCES.en and RESOURCES["zh-Hans"]
namespace by namespace and asserts both bundles cover the same key
set. i18next plural rule is normalized before compare (`_one` /
`_other` collapse to a single logical key) so EN's plural pair
matches zh's `_other`-only form.
Catches retrofit drift where a new EN key lands without zh —
previously this would silently fall back to the English string in
production. Cheap to keep green: 39 tests across 21 namespaces in
under a second.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate issues namespace
Translates the entire issues surface — list / board / detail / comments /
sub-issues / activity feed / batch toolbar / pickers / context menu /
backlog-agent hint dialog / labels panel.
Component coverage:
- issues-page (page header, empty state, move-failed toast)
- issues-header (scope tabs, filter dropdowns w/ status/priority/
assignee/creator/project/label, display settings, sort, view toggle)
- issue-detail (page header, breadcrumb, properties / parent issue /
details / token usage sections, sub-issues, activity timeline,
formatActivity for status/priority/assignee/title/due-date changes,
subscribe/subscriber popover)
- comment-card + comment-input + reply-input (delete dialog, edit/save,
copy/edit/delete row, reply count, placeholders, expand/collapse)
- agent-live-card (is-working banner, tool count, stop / transcript)
- execution-log-section (section header, show/hide past runs, trigger
text builder, status labels, cancel-task)
- batch-action-toolbar (selected count, delete dialog with plurals)
- backlog-agent-hint-dialog (full dialog content)
- labels-panel (intro, create form, list, delete dialog)
- pickers (status / priority / assignee / due-date / label / property
search placeholder + no-results)
- issue-actions-menu-items (all dropdown / context menu items)
- use-issue-actions / use-issue-timeline (toast strings)
STATUS_CONFIG / PRIORITY_CONFIG label rendering routed through
$.status[enum] / $.priority[enum] at every call site; the core config
keeps its English fallback for non-i18n consumers but UI never reads
.label directly anymore.
Tests retrofitted: issues-page, issue-detail, and issue-actions-menu
RTL specs now wrap renders in <I18nProvider> with the EN bundle, so
their string assertions match the bundle (not hardcoded literals).
ESLint i18next allow-list extended to 24 issues files. Verified:
pnpm --filter @multica/views typecheck + test (277/277) all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate agents namespace
Translates the agents listing + detail surface and the create/duplicate
flow. Covers the high-frequency surfaces; deeper sub-tab editors
(activity / instructions / skills / env / custom-args bodies, and the
hooks-buggy runtime/model/concurrency pickers) are deferred — they
have their own pre-existing react-hooks rule violations and benefit
from a focused dedicated pass.
Component coverage:
- agents-page (page header w/ tagline + new button, scope segment,
search, sort dropdown, availability chips, archived toolbar, empty
state, no-matches messaging w/ search interpolation, list-load
error)
- agent-detail-page (back link, archived banner, archive dialog,
not-found state, all 4 toast strings)
- agent-detail-inspector (avatar editor, name + description popover,
description dialog, every PropRow label, validation message,
presence badge label sourced from $.availability[enum])
- agent-overview-pane (tab labels, discard-unsaved-changes dialog)
- create-agent-dialog (title / description / labels / placeholders /
duplicate-suffix / runtime filter buttons / runtime status copy)
- agent-row-actions (full dropdown items + cancel-tasks dialog with
pluralized "N running + M queued" summary + archive dialog + 6 toasts)
- agent-columns (every header cell, You / Archived chips, runtime
fallback labels, availability + workload labels via $.availability /
$.workload, activity tooltip body w/ created_today / created_days_ago
/ runs / failed-percent interpolation)
- inspector/skill-attach (Attach trigger label + aria)
availabilityConfig and workloadConfig now keep colors only — the
display label lives in the bundle, sourced via $.availability[enum]
and $.workload[enum] at every call site. Same pattern as
STATUS_CONFIG/PRIORITY_CONFIG in the issues namespace.
ESLint i18next allow-list extended to 8 agents files.
Verified: pnpm --filter @multica/views typecheck + test (277/277)
all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): clear 30 stray EN strings in translated files
Tail of literal strings missed in earlier passes — the ESLint i18next
allow-list flagged them but they slipped through review. Files touched:
- layout/app-sidebar.tsx (10 keys: Workspaces / Pending invitations /
Create workspace / Join / Decline / Log out / New Issue + shortcut /
Pinned / Workspace / Configure)
- runtimes/components/runtime-detail.tsx (Serving header + serving_count
pluralization, no_agents copy, running/queued chips with count
interpolation, Diagnostics header, CLI label, Delete runtime button,
Technical details toggle, last seen interpolation)
- onboarding/steps/step-welcome.tsx (entire WelcomeIllustration mock —
5 cards × actor names + body copy + 3 mention chips + 2 timestamps;
zh translation reads naturally instead of leaving the demo English)
- settings/components/labs-tab.tsx (`Co-authored-by: ...` git trailer
wrapped in {} so linter sees a JS string, not JSX text — magic
identifier git relies on, must not translate)
- settings/components/members-tab.tsx (✓ glyph wrapped in {})
- modals/feedback.tsx (⌘↵ shortcut wrapped in {})
ServingAgentsCard now reads availability/workload labels from
`agents` namespace (cross-namespace useT) so the bundle-truth pattern
holds: presenceConfig keeps colours only, label text comes from the
shared bundle.
Verified: typecheck + 277/277 tests + lint (only the pre-existing
react-hooks rule-of-hooks errors remain, which task #6 addresses).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agents): rules-of-hooks + translate 4 model/runtime pickers
Three pre-existing react-hooks/rules-of-hooks violations + one missing
useMemo dep cleared, then the four pickers wired through useT.
Hook order fixes:
- concurrency-picker: useEffect now runs before the !canEdit early
return. Stale-draft reset still works the same way.
- runtime-picker: useMemo for the filtered list moved above the
!canEdit branch.
- model-dropdown: `models = data?.models ?? []` was minting a fresh
array each render and tripping the deps lint of the downstream
useMemo. Wrap in useMemo so the reference is stable.
Translation coverage:
- concurrency-picker: tooltip ("Concurrency · N max..."), range
helper text, Save button.
- runtime-picker: trigger label fallback ("No runtime"), tooltip
text composed from {{name}} + status, Mine/All filter buttons,
empty-list copy, "owned by {{name}}" + status fragments in row
tooltip, Cloud badge, online/offline aria.
- model-picker: trigger label, tooltip, "Managed by runtime"
fallback, search placeholder, "Discovering models…", default
badge, "No models available", "Use \"X\"" custom-id flow, Clear
button + its title.
- model-dropdown: every label string including the "Select a runtime
first" / "Default (provider)" / "Runtime offline — enter manually"
trigger fallbacks, the supported=false explanation block, discovery
failed badge, all popover items.
ESLint allow-list extended to 4 picker files. Verified: typecheck +
277/277 tests + lint (0 errors, only pre-existing react-hooks warnings
in unrelated files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate runtimes list + connect dialog + CLI updater
Three deep runtime surfaces wired through useT, with the agents
namespace doing double duty for shared availability/workload labels.
runtime-columns:
- 7 column headers via t-augmented createRuntimeColumns({ t }).
- HealthCell now reads from useHealthLabel() (already translation-aware)
instead of the EN-only healthLabel() helper.
- WorkloadCell sources the label from $.workload[enum] (cross-namespace
to agents) — colour stays via workloadConfig.
- CostCell delta "flat" copy + CLI cell "Desktop" badge + update-
available aria/tooltip + RowMenu's full delete dialog (title /
description with {{name}} interpolation / cancel / confirm /
deleting state) plus its admin-permission hint.
connect-remote-dialog:
- Three steps fully translated: instructions (header + 4 numbered
steps + security warning + troubleshooting list with mono code
snippets escaped as JS strings), waiting (loader + hint), success
(CTA pair).
- Mono CLI commands wrapped in {} so linter sees JS strings — those
are literal commands that must stay untranslated for the user to
paste into a terminal.
update-section:
- statusConfig collapsed to icon+colour only; labels move to
$.update.status[enum] for proper translation per-state.
- "CLI Version:" / "Latest" / "available" / "Update" / "Retry"
copy + the "Managed by Desktop" tooltip and disabled hint.
Layout helpers tagged: runtime-list passes `t` through to the column
factory the same way agent-columns does.
ESLint allow-list extended with the 4 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. usage-section.tsx
(KPI cards / WhenChart / TopUsageBreakdown / receipt table) is the
remaining runtimes surface — chart-heavy and benefits from a focused
pass next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate 5 agent detail tabs + skill-add dialog
The 5 tabs that fill the agent detail right pane plus the shared
skill picker dialog. Agents bundle gains a `tab_body` block with
sub-namespaces per tab + a `common` slot for save/add/unsaved.
Tab coverage:
- instructions-tab: intro paragraph, multi-line example placeholder
(full 18-line zh translation), Save / Unsaved.
- env-tab: read-only intro / empty state, editable intro with two
inline `<code>` env-var examples kept English (mono terminal
payloads), KEY / value placeholders, Show/Hide value aria, Add /
Remove aria, all 3 toasts (duplicate keys / saved / save failed).
- custom-args-tab: intro about whitespace splitting, launch-mode
prefix line + `<your args>` placeholder, --flag value placeholder,
Add, Remove aria, both toasts.
- skills-tab: intro, Add skill button, import-hint callout, empty
state title + hint + add-CTA, remove-failed toast.
- activity-tab: 3 section titles (Now / Last 30 days / Recent work),
active-task pluralization, performance subtitle, all 3 empty
states, runs/success%/avg-duration/failed pluralization with
interpolation, source labels (Issue / Chat / Autopilot / Untracked),
source fallbacks (Quick create / Creating issue / Chat session /
Autopilot run), issue-short fallback, "Triggered by" tooltip
header, open-issue / transcript / cancel-task tooltips and ARIAs,
cancelling state, started/dispatched/queued time prefixes, show
more.
- skill-add-dialog: dialog title + description, empty list copy,
Cancel button, add-failed toast.
skills-tab.test.tsx wrapped in <I18nProvider> with the EN bundle so
its `Local runtime skills are always available` assertion still
matches the resolved translation instead of the raw key path.
ESLint allow-list extended with the 6 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. Only the per-test
mock for skills-tab needed wrapping; the other 4 tabs ship without
test files of their own and inherit the I18nProvider chain via
agent-overview-pane / agent-detail-page test renders (when those
exist later).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate onboarding step-questionnaire + option-card
The user-profile step (3 questions) is the first deferred onboarding
deep step now wired through useT.
step-questionnaire:
- Eyebrow + headline + answered-progress counter with {{count}}
interpolation
- All 3 questions and their option labels (team size / role / use case)
- All 3 "Other" placeholders for free-text fallback
- Right-rail "Why three questions" / "What you get" panel: 2 eyebrow
rows, 2 unlock-item title+body pairs, learn-more link
- Back / Continue buttons via shared `common` block
option-card: shared "Other" radio label and aria.
Test wrapped in <I18nProvider>. EN value of `other_label` kept as
"Other" so the existing /^other$/i regex in step-questionnaire.test
keeps matching after the rendering pipeline switched from a hardcoded
literal to a bundle lookup.
ESLint allow-list extended with these 2 files. The remaining 4 deep
steps (workspace / runtime-connect / platform-fork / agent), the
2 ancillary surfaces (cli-install-instructions / starter-content-
prompt), and the 3 side panels (runtime-aside-panel / cloud-waitlist-
expand / compact-runtime-row) will be surfaced + swept by the global
ESLint switch (next commit).
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): flip ESLint to glob + drain remaining hardcoded EN
ESLint i18next/no-literal-string now applies to **/*.tsx by default
instead of an explicit allow-list. Files that genuinely still need
hardcoded EN are listed in STILL_HARDCODED — concrete, finite, and
the goal is to drain that list to zero.
Tail strings translated in this commit (surfaced by the global flip):
- common/task-transcript/agent-transcript-dialog.tsx — full dialog:
status badge (Running / Completed / Failed), sr-only DialogTitle,
Filter dropdown trigger + Clear filters, Copy all / Copy filtered /
Copied, tool-calls + events metadata chips with pluralization,
events-filtered "{{shown}} of {{total}}" interpolation, "Waiting
for events..." live state, "No execution data recorded." past
state. New `transcript` block in agents namespace.
- runtimes/components/charts/activity-heatmap.tsx — Less / More
legend labels around the contribution-style heat squares.
- search/search-trigger.tsx — sidebar Search... button label.
⌘ glyph wrapped in {} to satisfy the linter (mono shortcut symbol,
not translatable).
Holdouts (STILL_HARDCODED, ~14 files): the deep onboarding steps
(workspace / runtime-connect / platform-fork / agent / first-issue /
cli-install-instructions, plus 4 ancillary panels), the runtimes
usage-section + KPI cards, and 5 minor agent visual primitives
(sparkline / agent-presence-indicator / agent-profile-card /
visibility-badge / char-counter). Each one gets a dedicated future
pass; the global rule prevents new hardcoded strings from landing
elsewhere.
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): drain agent visual primitives + onboarding small components
8 files removed from STILL_HARDCODED:
agents/components/:
- char-counter — over-limit text with {{count}} interpolation
- visibility-badge — uses new agents.visibility.{private,workspace}.
{label,tooltip} block; drops VISIBILITY_LABEL/TOOLTIP imports from
core in favour of bundle-driven copy
- agent-presence-indicator — availability + workload labels via
$.availability[enum] / $.workload[enum] (cross-namespace),
queue-badge "+N queued" with pluralization
- agent-profile-card — Agent unavailable / Detail link / Owner /
Skills / Runtime / Unknown runtime / Archived chip / availability
line via cross-namespace lookup
agents.json: new presence + visibility + profile_card + char_counter
blocks.
onboarding/components/:
- compact-runtime-row — online/offline aria via agents.availability
- runtime-aside-panel — full content (What's a runtime / Good to
know / Swap anytime / Add more later / docs link)
- starter-content-prompt — full dialog (title / description with
inline emphasis / both buttons / 3 toasts)
- cloud-waitlist-expand — intro paragraph + warning span / email
+ reason labels + placeholders + Optional badge / Join + on-list
states / both toasts
onboarding/steps/:
- cli-install-instructions — copy aria + intro + 2 step labels
onboarding.json: new runtime_aside / cli_install / starter_content /
cloud_waitlist blocks.
Tests for step-platform-fork + step-runtime-connect wrapped in
<I18nProvider> with EN bundle so /you're on the list/i etc. still
matches the resolved translations.
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate onboarding deep steps
The 5 large onboarding steps that were deferred from earlier passes,
plus their support helpers, all wired through useT.
step-first-issue (final beat — flips onboarded_at):
- error_title / Retry / retry_failed toast / finishing / opening
states.
step-agent (creates the user's first agent):
- Templates moved from a module-level const to a useT-driven
useAgentTemplates() hook. Names + emoji stay constant (visual
identity), labels + blurbs + instructions resolve from the
bundle. coding / planning / writing / assistant — all four
templates ship a full zh translation that reads naturally.
- Recommended badge, eyebrow + headline + lede, footer hint,
Create {{name}} CTA, create_failed toast.
- Right-rail "About agents" panel (4 way-items + headline +
add-more hint + docs link).
step-workspace (create or pick existing):
- 5 footer states (open / creating / creating-pending / name-first
/ pick), all hint + CTA strings via interpolation.
- Name + URL + slug placeholders, issue-prefix preview spans,
Create-new card title + subtitle.
- 8-row WorkspacePreviewCard sidebar (Inbox / Issues / Agents /
Projects / Autopilot / Runtimes / Skills / And more) — every
label + meta strapped to bundle keys.
- 4 perks (assign / chat / invite / switch) + 3 next-steps
(runtime / agent / starter), 2 toasts (slug-conflict / failed).
- `multica.ai/${slug}` mono URL escaped via template-literal
expression so the linter sees a JS string.
step-runtime-connect (desktop scan flow):
- 3 phase headlines + ledes (scanning / found / empty), trust-strip
status (all online / N online / none online) with pluralization,
online/offline labels, Skip / Continue / Selected hint.
- Empty-view 2 cards (skip + waitlist) and the cloud waitlist
dialog wrapper.
step-platform-fork (web fan-out):
- Eyebrow + headline + lede, footer hint with 3 phase variants.
- Primary download card (before/after click) + 2 alt cards (CLI /
cloud) + CLI dialog with 4 elapsed-time stages (normal / midway /
slow / stalled), live-listening header, runtime-connected
pluralization, cloud waitlist dialog.
ESLint: STILL_HARDCODED list shrunk from 14 entries to 1 — only
runtimes/components/usage-section.tsx (chart-heavy KPI panel)
remains.
Verified: typecheck + 277/277 tests + 0 i18n lint errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate runtimes usage panel + drop STILL_HARDCODED
Final i18n holdout: the runtimes usage panel (KPI hero, WHEN chart
tabs, cost-by breakdowns, daily breakdown table) is wired through
useT("runtimes"). With this drained, the eslint scaffolding for
explicit holdouts is removed — every JSX text node in @multica/views
now flows through i18n.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): drain rollout gaps + add cross-device sync
Lands the post-review punch list for the i18n rollout: closes correctness
gaps that would have shipped silently, and adds the missing cross-device
locale sync the rollout's docs already promised.
Coverage:
- Register issues + agents namespaces in RESOURCES (90 useT call sites
were rendering keys-as-text in production)
- Harden parity test to compare RESOURCES keys against on-disk JSON
files, so a future missing namespace registration fails loudly
- Server-side language whitelist in UpdateMe + reject-unsupported test
- Safe SupportedLocale resolution in appearance-tab (no more `as` cast
on a region-tagged BCP-47 string)
- HTML lang attribute uses zh-CN (not zh-Hans) for screen reader / CJK
font-stack compatibility
- Cookie Secure flag on https
- Pulled createBrowserCookieLocaleAdapter out of the server-safe entry
into a new @multica/core/i18n/browser subpath; document.cookie access
can no longer leak into Edge middleware imports
Cross-device sync:
- New UserLocaleSync component mounted in CoreProvider; on login, if
user.language differs from the active i18n.language, persist via the
adapter and reload. Both apps benefit
- Desktop main process tracks system locale and emits IPC on focus when
it changes; renderer reloads only when the user has no explicit
Settings choice (their preference still wins)
Tests:
- pickLocale / matchLocale (11 cases incl. region-tagged BCP-47, malformed
tags, zh-Hant collapse-to-zh-Hans semantics)
- browser-cookie-adapter (6 cases under jsdom)
- Shared renderWithI18n helper at packages/views/test/i18n.tsx that wraps
the real RESOURCES map; future tests opt in instead of inlining a
per-file TEST_RESOURCES slice that goes stale silently
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(conventions): consolidate naming + i18n glossary into docs site
Single source of truth for code naming, i18n translation glossary, and
Chinese voice rules. Previously split between packages/views/locales/glossary.md
and scattered comments — now lives at apps/docs/content/docs/developers/conventions.{mdx,zh.mdx}
with both English and Chinese versions kept in sync.
Three sections per page:
1. Code naming — routes, packages, files, DB, Go, TS, commits
2. i18n translation glossary — entity vs concept rule, what to translate,
word combination, plurals, interpolation, key naming
3. Chinese voice + style — punctuation, principles, where to look in doubt
Side effects:
- packages/views/locales/glossary.md collapses to a stub redirecting to
the docs page; do not edit it
- CLAUDE.md gets a new top-level "Conventions reference" section so any
Claude session sees the pointer before any other rule
- apps/docs/content/docs/developers/ gets a stub English meta.json so the
conventions page is reachable on the EN side (contributing.zh.mdx /
architecture.zh.mdx remain ZH-only — separate work)
- Both root sidebars get a new "Developers" group
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(i18n): apply zh voice rules + translate project/autopilot
Two-part cleanup driven by the conventions doc landed last commit:
Voice violations (mechanical sweep across 10 zh-Hans namespaces):
- 「」 (Japanese-style brackets) → \" to match the EN source's straight
double quotes (~13 sites)
- … (single-char ellipsis) → ... three dots (~43 sites)
- Drop translation-ese pronoun "我们" where it's a pure narrator
("我们已发送" → "已发送", "我们替你托管" → "由 Multica 托管"); keep
"告诉我们" where "we" is the legitimate brand recipient
- "作为父级 / 作为子级" → "设为父级 / 设为子级"
- "任务" mistranslated as the task entity → `task` (lowercase EN entity)
- Dialog title "Autopilot" → "autopilot"
Translate project / autopilot per industry consensus:
- `project` → 「项目」 (~42 value sites). Feishu / Tower / Teambition /
PingCode / GitHub Projects all translate; no Chinese product keeps
`project`.
- `autopilot` → 「自动化」 (~34 value sites). Avoids the Tesla-style
「自动驾驶」 association; matches Notion / Feishu's industry term.
- Issue / skill / task remain lowercase EN per dev-team familiarity.
- Sidebar nav-label entities get Title Case ("Issue" / "Skill" / "我的
Issue") so the entry-point label reads as a proper UI signal; body
prose stays lowercase.
Conventions doc (EN + ZH) reflects the decision and adds a "why these
translate but issue/skill/task don't" rationale block.
Verification: parity test 45/45, full monorepo typecheck green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): translate chat session delete + project resources section
Two features main shipped while this branch was idle never went through
the i18n pass:
- Chat session delete confirmation dialog (#2115) and history toggle
tooltip (#2117): adds session_history.delete_dialog.* and
session_history.row_delete_*, plus window.history_show_tooltip /
history_back_tooltip.
- Project resources sidebar (#1926/#2080/#2111): entire component
including toasts, popover form, attach/remove tooltips. New
projects.resources subtree.
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(permissions): add core permission module and shared UI primitives
Foundation for permission-aware UI: pure rules that mirror the Go backend
permission gates, lightweight per-resource hooks, and two reusable display
components used across agent/skill/runtime detail pages.
- packages/core/permissions: types, rules, hooks (Decision-shaped — carries
reason + message so UI can render disabled state, tooltip, and banner
copy from one source)
- packages/core/agents/visibility-label: VISIBILITY_LABEL/DESCRIPTION/TOOLTIP
constants ("Personal" / "Workspace") to replace scattered hard-coded copy
- packages/views/agents/visibility-badge: read-only visibility chip used on
hover cards, list rows, and inspector when not editable
- packages/ui/components/common/capability-banner: "View only — only X and
admins can edit Y" banner shown on agent / skill detail when current user
lacks edit permission
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): permission-aware UI across agent/comment/runtime/skill surfaces
Apply the new permission rules to every surface where the UI was either
lying about who can do what or letting users hit 403s by clicking buttons
the backend would reject.
Agent detail
- Hide archive/restore actions for non-owner non-admin
- Replace inline editors (avatar, name, description, runtime/model/visibility/
concurrency picker, skill-attach) with read-only display when canEdit is
false — value is information, the editor is the action
- Show CapabilityBanner under the header explaining who can edit
Visibility surfaces
- visibility-picker / create-agent-dialog: replace "only you can assign"
(false) with "Only you and workspace admins can assign" via shared
VISIBILITY_DESCRIPTION constants
- agent-columns: truthful tooltip + "You" badge on agents the current user
owns
Comments
- Restore admin override on comment edit/delete (backend already permits
it via comment.go:507-512; the frontend was incorrectly hiding the menu).
canModerate is computed once in issue-detail and threaded down.
Other
- Members tab: disable "demote" options for the last owner with tooltip
- Assignee picker: tooltip on disabled personal agents the user can't assign
- Runtime delete: tooltip and dialog explain the gate; owner column gains
a name label next to the avatar in All scope
- Skill detail: page-level CapabilityBanner alongside the existing lock chip
- Issue delete (single + batch): note that any workspace member can delete
issues — by-design semantics, made transparent
Backend is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): hide personal agents from list and @mention for non-owners
Until now an agent's "Personal" visibility only narrowed the assign-to-issue
gate — every workspace member still saw every personal agent in the list
and the @mention dropdown. Members would see, click, and fail.
This filters those surfaces with the canonical canAssignAgentToIssue rule:
regular members only see workspace-visibility agents and the personal
agents they own; workspace owners and admins continue to see everything
(admin override path is intact).
- agents-page: visibleInView layer between active/archived and Mine/All
scope so segment counts also reflect the filter
- mention-suggestion: filter agentItems before they enter the recency-
ranked list; expand the test mock to cover the auth + visibility paths
and add two assertions (member hides others' personal agents; admin
still sees them)
Backend keeps returning every agent — admin tools and direct API access
are unaffected. This is a UI-only filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users can now mute specific notification categories (assignments, status
changes, comments & mentions, priority/due-date updates, agent activity)
from Settings > Notifications. Muted event types are silently filtered at
notification creation time — no inbox items are created for muted groups.
- Add notification_preference table (migration 064)
- Add GET/PUT /api/notification-preferences endpoints
- Filter notifications in notifyIssueSubscribers, notifyDirect, and
notifyMentionedMembers based on user preferences
- Add Notifications tab in Settings with per-group toggle switches
* feat(agent-status): add workspace live-tasks endpoint and TaskFailureReason type
Lays the API + type contract for the front-end agent presence cache:
- New `GET /api/active-tasks` returns active (queued/dispatched/running)
tasks plus failed tasks within the last 2 minutes for the current
workspace. The 2-minute window powers a UI-side auto-clearing "Failed"
agent state without back-end pollers.
- `agent_task_queue` has no workspace_id column, so the query JOINs agent;
`SELECT atq.*` keeps `failure_reason` (migration 055) on the wire.
- Adds `TaskFailureReason` to `AgentTask` so the UI can map the 5 backend
classifiers (agent_error / timeout / runtime_offline / runtime_recovery
/ manual) to copy without parsing free-text errors.
- New `api.getActiveTasksForWorkspace()` client method; workspace is
resolved server-side from the X-Workspace-Slug header (no path param,
matching /api/agents and /api/runtimes conventions).
Includes the joint engineering plan and designer brief that scope the
broader Agent / Runtime status redesign — Phase 0 is this contract plus
the front-end derivation layer landing in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-status): derive presence/health states with WS sync and desktop IPC bridge
Adds the front-end derivation layer that turns raw server data into the
user-facing 5-state agent / 4-state runtime enums. UI files are
deliberately untouched in this commit — derivation lives behind hooks
(useAgentPresence, useRuntimeHealth) that any component can call with
zero additional network traffic.
Architecture:
- Derivation is pure functions in packages/core/{agents,runtimes}; the
back-end stays free of UI translation. Agents algorithm: runtime
offline > recent failed (2-min window) > running > queued > available.
Runtimes algorithm: status + last_seen_at -> online / recently_lost /
offline / about_to_gc.
- A single workspace-wide active-tasks query backs all per-agent
presence reads, eliminating N+1 across hover cards, list rows, and
pickers. 30-second tick re-renders the hooks so the failed window
expires even when no underlying data changes.
- WS task lifecycle events (dispatch / completed / failed / cancelled)
invalidate active-tasks via the prefix dispatcher. completed/failed
were removed from specificEvents so they go through both the prefix
invalidate and the existing chat ws.on() handlers. Reconnect refetch
picks up active-tasks too.
- Desktop bridges window.daemonAPI.onStatusChange directly into the
runtimes cache via setQueryData, giving the local daemon sub-second
feedback (vs. 75s server sweep). Bridge is wsId-bound so workspace
switches automatically rebind the subscription; daemon_id matching
covers the same-daemon-multiple-providers case.
24 derivation unit tests cover all branches plus null/empty/boundary
inputs (FAILED_WINDOW_MS edges, null last_seen_at, missing
completed_at). Full core suite: 112 tests passing. Typecheck green
across all 8 workspace packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-status): redesign agent runtime status as two orthogonal dimensions
Splits the conflated 5-state agent presence into two independent axes:
- AgentAvailability (3-state): online / unstable / offline — drives the
dot indicator everywhere a dot appears. Pure runtime reachability;
never sticky-red because of a past task outcome.
- LastTaskState (5-state): running / completed / failed / cancelled /
idle — surfaced as text + icon on focused surfaces (hover card,
agent detail page, agents list, runtime detail). Never colours the dot.
Major changes:
* Domain layer: AgentPresence union → AgentAvailability + LastTaskState.
derive-presence split into deriveAgentAvailability + deriveLastTaskState
+ deriveAgentPresenceDetail orchestrator. Tests reorganised into three
groups (availability invariants, last-task invariants, composition).
* Visual config: presenceConfig (5 entries) → availabilityConfig (3) +
taskStateConfig (5). availabilityOrder + lastTaskOrder for filter chips.
* Workspace-level presence prefetch: new useWorkspacePresencePrefetch
hook + WorkspacePresencePrefetch mount component, wired into
DashboardLayout (web) and WorkspaceRouteLayout (desktop). Hover cards
render synchronously with no skeleton flash on first hover.
* ActorAvatar hover: flipped default — disableHoverCard removed,
enableHoverCard added (default false). Opt-in at ~14 decision-moment
surfaces; pickers / decoration sub-chips stay plain. Status dot
decoupled (showStatusDot prop) so picker rows can show presence
without nesting popovers.
* Hover cards: AgentProfileCard simplified — availability dot only,
Detail link top-right (logs live on the detail page). New
MemberProfileCard mirrors the structure: name + role + email +
top-2 owned agents (sorted by 30d run count) with click-through to
agent detail.
* Agents list: split Status into two columns — availability (3-color
dot + label) and Last run (task icon + label, optional running
counts). Two independent filter chip groups (Status + Last run);
combination acts as intersection ("online + failed" finds broken-
but-alive agents).
* Other UI surfaces (issue list/board/detail, comments, autopilots,
projects, runtimes, mention autocomplete, subscribers picker)
updated to the new dot semantics; status dot now strictly 3-color.
Server changes accompany the client redesign — workspace-wide
agent-task-snapshot endpoint, runtime usage queries, etc. — to feed
the derive layer with the data it needs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-detail): drop last-task chip from detail header + inspector
The Recent work section on the agent detail page already shows the same
data (with task titles, timestamps, error context) — surfacing
"Completed" / "Failed" / etc. up in the header was redundant chrome.
Detail surfaces now show only the 3-state availability dot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tables): handle narrow viewports across agents / skills / runtimes
Three table layouts were squeezing content into adjacent cells at
intermediate widths. Each fix is small and targeted:
* runtime-list: the Runtime cell's base name had `shrink-0`, so it
refused to truncate when its grid column was narrowed under width
pressure — the name visually overflowed into the Health column
("ClaudeOnline" etc). Removed shrink-0, added truncate. The Health
column was also a fixed 9.5rem reservation for the worst-case
"Recently lost · 2m 14s ago" copy; switched to minmax(0,1fr) so it
competes fairly with Runtime.
* skills-page: had a single grid template with no responsive
breakpoints — all 6 columns were rendered at any width and got
visually jammed below md. Added a <md template that drops Source +
Updated; the row markup hides those cells via `hidden md:block` /
`md:contents`.
* agent-list-item: the new Last run column was reserved at minmax(8rem,
max-content); on narrow md viewports the 8rem floor pushed the row
past available width. Changed to minmax(0,max-content) so the cell
shrinks under pressure (its content already truncates).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-card): hover-only Detail + add Runtime row + breathing room
Three small polish tweaks to the agent hover card:
- Detail link gets `mr-1` + fades in only on card hover (group-hover).
It was visually flush against the popover edge and competing for
attention; now it stays out of the way during a quick glance and
surfaces only when the user is dwelling on the card.
- Runtime row is back, in the meta block (cloud/local icon + runtime
name). The earlier removal was over-aggressive — knowing where an
agent runs is part of "who is this agent". The wifi badge stays
dropped because the availability dot in the header already conveys
reachability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(runtime): wifi-style health icon (4-state) for runtime list + agent card
Replaces the 6px coloured dot with a wifi-shape icon that carries both
state (Wifi vs WifiOff) and severity (success/warning/muted/destructive).
Mapping:
- online → Wifi (success)
- recently_lost → WifiHigh (warning) — transient hiccup, fewer bars
- offline → WifiOff (muted) — long unreachable
- about_to_gc → WifiOff (destructive) — sweeper coming soon
Used in two places:
- Runtime list: replaces HealthDot in the dedicated leading-icon column.
Bumped the column from 0.5rem (dot-sized) to 0.875rem (icon-sized).
- Agent profile card RuntimeRow: derives runtime health from runtime +
clock (matching the 4-state semantics) and renders HealthIcon next
to the runtime name. Cloud runtimes always read as online. The
duplicate signal with the header availability dot is intentional —
it confirms WHICH runtime is the one currently in the dot's state.
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(labels): add issue label CRUD + attach/detach handlers (#1191)
The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql
but never wired to any code path. This commit ships the backend for #1191:
- Migration 048: adds created_at/updated_at timestamps + workspace-scoped
case-insensitive unique index on label names
- sqlc queries for label CRUD + issue<->label attach/detach + batch list
(ListLabelsByIssueIDs for board/list views)
- HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach
- Protocol events: label:{created,updated,deleted} + issue_labels:changed
- Handler tests covering CRUD, duplicate-name conflict, invalid-color,
attach/detach idempotency, and cross-workspace isolation
* feat(cli): add label and issue label subcommands (#1191)
- multica label {list,get,create,update,delete}
- multica issue label {list,add,remove}
Both follow existing CLI conventions (JSON/table output, flag shapes)
and exercise the /api/labels endpoints shipped in the previous commit.
* feat(web): add labels UI — picker with inline create + management dialog (#1191)
Exposes the backend label feature to users via the existing issue-detail
sidebar.
- `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest,
plus response envelopes
- `@multica/core/api/client` — 8 methods for label CRUD and issue↔label
attach/detach
- `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with
optimistic updates (matches the project/ module layout)
- WS event type literals extended for label:{created,updated,deleted} and
issue_labels:changed
- `views/labels/label-chip.tsx` — colored pill; uses relative luminance
(ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on
both pastel and saturated backgrounds
- `views/issues/components/pickers/label-picker.tsx`
- Multi-select combobox in the issue sidebar
- When 0 labels: "Add label" trigger
- When 1+ labels: the chips themselves are the trigger; × on each chip
detaches without opening the picker
- Inline create: typing a new name + Enter creates with a hash-derived
color and attaches in one motion (matches Linear/GitHub)
- "Manage labels…" footer opens a dialog containing the full workspace
panel — users never leave the issue context to rename/recolor/delete
- `views/issues/components/labels-panel.tsx` — workspace labels manager.
Single-row create form (color swatch + name + Add button). Each label
row supports inline rename + recolor + delete (with confirm dialog).
Color input uses the browser's native picker for full-gamut access —
no preset palette clutter.
- `PropRow label="Labels"` added to the issue-detail sidebar below Project
Labels are issue metadata everyone uses — not admin configuration.
Putting them in Settings next to destructive workspace actions misframed
them; adding a top-level nav entry or a sibling tab to the Issues page
added surface area that wasn't earning its keep for a feature users
touch occasionally. Keeping management in a dialog launched from the
picker itself keeps users in their issue context and matches how GitHub
handles label editing from the label selector.
* feat(feedback): add in-app feedback flow and Help launcher
Replaces the duplicated bottom-sidebar user popover and "What's new" links
with a single Help menu (Docs / Feedback / Change log) pinned to the
sidebar footer. Feedback opens a rich-text modal that POSTs to a new
/api/feedback endpoint; submissions land in a dedicated feedback table
with per-user hourly rate limiting (10/hr) to deter spam without adding
middleware infrastructure. User identity (avatar + name + email) moves
into the workspace dropdown header so the sidebar is no longer visually
redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(feedback): harden submit path and cap request body
- Read editor markdown via ref at submit time instead of debounced state,
so ⌘+Enter immediately after typing doesn't drop the last keystrokes.
- Block submission while images are still uploading; toast prompts the
user to wait instead of silently sending markdown with blob: URLs
that get stripped.
- Cap /api/feedback request body at 64 KiB via MaxBytesReader so an
authenticated client can't bloat the metadata JSONB column with an
oversized url field.
- Add Go handler tests covering happy path, empty-message rejection,
and the hourly rate limit boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(analytics): instrument feedback funnel
Adds two events pairing frontend intent with backend conversion so we
can compute a completion rate for the in-app Feedback modal:
- `feedback_opened` (frontend) — fires once on FeedbackModal mount.
Source is currently always "help_menu" but the type is a union so
future entry points have to extend it explicitly. Workspace id is
attached when present.
- `feedback_submitted` (backend) — fires from CreateFeedback after the
DB insert succeeds and the hourly rate-limit check has passed.
Message content itself is never sent to PostHog; the event carries
a coarse length bucket (0-100 / 100-500 / 500-2000 / 2000+), an
image-presence flag, and the client platform / version pulled from
X-Client-* headers via middleware.ClientMetadataFromContext.
Affects no existing funnel; seeds a new Feedback funnel for product
triage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): add redesign proposal
Captures motivation (two activation funnels), research-backed principles,
final 5-step flow (welcome+questionnaire → workspace → runtime → agent →
first-issue), Q1/Q2/Q3 personalization matrix, backend user_onboarding
schema, API design, resume policy, and development ordering
(frontend-first with Zustand stub, backend-last, server swap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): scaffold redesigned flow and state foundation
Work-in-progress scaffold toward the redesign documented in
docs/onboarding-redesign-proposal.md. This commit is intentionally
broad — subsequent commits will replace step content and wire real
personalization. Not ready for merge.
Included:
- packages/views/onboarding/: flow orchestrator + 5 step components
(welcome/workspace/runtime/agent/complete) and the CLI install card.
Step content is the placeholder version; Step 1 (questionnaire) and
Step 5 (first issue) are the next changes.
- packages/core/onboarding/: dev-phase Zustand store + types. Not
persisted — every page refresh starts at Step 1 so each step can be
iterated in isolation. Will swap to TanStack Query + PATCH
/api/me/onboarding once the backend user_onboarding table ships
(keeps the exported hook surface stable).
- packages/core/paths/resolve.ts + .test.ts: centralized
resolvePostAuthDestination. Priority is flipped so !hasOnboarded
wins over workspace presence — during frontend development every
login re-enters /onboarding. useHasOnboarded() reads from the store
so the real onboarded_at semantic lands automatically once the
backend ships.
- Post-auth wiring: callback page, login page, landing redirect,
dashboard guard, realtime workspace-loss handler, settings leave/
delete, invite acceptance, and desktop app shell all delegate to
the shared resolver instead of inline logic.
- Desktop overlay: 'onboarding' added as a WindowOverlay type
alongside new-workspace / invite, with a navigation-adapter
interception so push('/onboarding') opens the overlay.
- packages/core/package.json / packages/views/package.json: add new
subpath exports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): revise questionnaire to role-driven 3-question form
Aligns the proposal with the corrected product positioning: Multica is an
AI agent orchestration platform for diverse users (developers, product
leads, writers, founders), not a coding-focused tool.
Key changes:
- Drop Q1 "which agents do you already use?" — daemon auto-detects
installed CLIs on PATH; asking is both redundant and less accurate
- Add Q2 "what best describes you?" (role) to drive Step 4 template
default and Onboarding Project sub-issue filtering
- Keep Q1 team_size, refine Q3 use_case (recover writing/research
option); all three now have "Other" with an 80-char text field
- Q3 use_case_other is embedded into Step 5 first issue prompt so
Other users get maximally personalized aha moments, not generic ones
- Agent templates: 3 → 4 (Coding / Planning / Writing / Assistant),
matrix driven by Q2 × Q3
- Onboarding Project sub-issues: surface Autopilot and Workspace
Context (product differentiators), replace "orchestration" wording
- Schema JSONB example and §5/§9 execution plan updated to match
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): align questionnaire shape with role-driven redesign
Prepares the core state layer for the Step 1 questionnaire rewrite.
Type-only and initial-value changes; no behavior changes (nothing was
reading the removed `existing_agents` field, since no questionnaire UI
exists yet).
- Add `Role` type (Q2: developer / product_lead / writer / founder / other)
- Add `*_other` sibling fields for team_size / role / use_case so each
question's "Other" selection can carry 80-char free text
- Drop `existing_agents` — daemon auto-detects CLIs on PATH at Step 3,
so the signal no longer belongs in the questionnaire
- Extend `TeamSize` / `UseCase` unions with `"other"` member
- Refine `UseCase` option label (`writing` → `writing_research`) so
it matches the widened Q3 scope in the proposal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): implement Step 1 questionnaire
Replaces the placeholder welcome step with the 3-question questionnaire
defined in docs/onboarding-redesign-proposal.md §3.4. Answers land in
the core onboarding store for later use by Steps 4 and 5.
Added:
- packages/views/onboarding/components/option-card.tsx — OptionCard +
OtherOptionCard. Radio-group ARIA semantics; Enter/Space select;
Other variant reveals an 80-char input that auto-focuses on mount.
- packages/views/onboarding/steps/step-questionnaire.tsx — merges
welcome + Q1/Q2/Q3 into one screen. Local draft state for
responsiveness; writes to the core store only on submit. Skip/
Continue CTA swap driven by "any answered?"; the only disabled
case is "picked Other but the text box is blank".
- Test coverage for the CTA rules, Other-clear-on-switch behavior,
initial-answers pre-fill, and full payload shape.
Modified:
- packages/views/onboarding/onboarding-flow.tsx — render
questionnaire as the first step; persist answers and advance the
stored current_step on submit. Other steps still run off local
useState for now; full store-driven orchestration follows when
Step 5 lands.
Removed:
- packages/views/onboarding/steps/step-welcome.tsx — superseded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): split welcome + questionnaire, unblock scroll, drop Q1 evaluating
Three fixes prompted by first real browser testing of the Step 1
questionnaire. All three are about making the flow usable before
pursuing visual polish.
1. Split Welcome and Questionnaire into two screens
The previous merge-welcome-into-questionnaire decision dropped
Multica's product introduction entirely. For a product with no
established mental model (AI agents as first-class teammates in a
task platform), first-time users need 5 seconds of framing before
the questionnaire makes sense. StepWelcome carries that framing;
it's UI-only (not a persisted step), shown only on first entry
(pristine store), and skipped automatically on resume.
2. Remove `my-auto` vertical centering from both platform shells
Long questionnaire content pushed the centered block's top above
the scroll origin, making Continue/Skip unreachable. Top-alignment
+ natural body/overlay scroll is the boring-but-correct baseline
for content of variable height.
3. Drop Q1 "Just exploring for now" option
Q1 asks about team structure, not attitude. "Evaluating" was a
category error. Low-commitment users already have a zero-friction
path (skip all questions). Removing the option simplifies the
question and the downstream mapping table.
Types, store initial value, proposal doc (§3.1 flow diagram, §3.4
options, §3.5 sub-issue sorting, §3.6 conditionals, §4.1 JSONB
schema, §5.2 file list, §7 decisions row, §9.2 execution order)
all synced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): center short steps, scroll long ones — correctly this time
Previous attempt removed `my-auto` thinking it was responsible for
blocked scrolling. That diagnosis was wrong: the real blocker was
the root layout's \`body { overflow: hidden }\` (an app-shell
convention so sidebar/topbar stay put while the inner content
region scrolls). Removing `my-auto` broke vertical centering of
short steps (Welcome) without fixing the scroll issue.
Correct fix:
- Web: page now owns its own scroll container — `h-full
overflow-y-auto` on the outermost div decouples from the body's
overflow-hidden.
- Desktop: the overlay's existing `flex-1 overflow-auto` container
already provided scroll; just restoring `my-auto` was sufficient.
- Both platforms: inner `flex min-h-full flex-col items-center` +
content `my-auto` gives the "short centers, long top-aligns and
overflows down" behavior. Per the flex spec, auto margins are
ignored on overflowing boxes (they overflow in the end direction),
so Continue/Skip remain reachable via scroll even on long steps
like the questionnaire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): add progress indicator + stable header anchor
Adds a consistent visual anchor at the top of every step (except
Welcome), so transitioning between steps of different content heights
no longer shifts the vertical baseline.
- packages/core/onboarding/step-order.ts — single source of truth for
step order; indicator math reads from here so adding/reordering a
step touches only one line
- packages/views/onboarding/components/step-header.tsx — dot row +
"Step N of M" counter; three dot states (done/current/pending);
accessible progressbar semantics
- onboarding-flow.tsx — non-welcome steps now render under a shared
`<div flex flex-col gap-8>` wrapper with StepHeader on top. Maps
the local `complete` render step to the store's `first_issue`
until Step 5 lands (one-line function, self-deleting).
- step-welcome.tsx — keeps its own min-h-[60vh] + justify-center so
the short intro still feels centered once the shell drops my-auto
- apps/web + apps/desktop shells — removed `my-auto`. Every
non-welcome step now anchors to the same top position, so only the
content below the header changes during transitions. Welcome's own
internal centering handles its "short content, no header" case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): add web Step 3 platform fork (Desktop / CLI / waitlist)
Web users now see a three-way choice at the runtime step instead of
being dropped directly into CLI install instructions:
- Primary CTA: Download Multica Desktop (bundled runtime)
- Alternate: install the CLI (reveals existing StepRuntimeConnect)
- Alternate: join the cloud waitlist (captures email, completes
onboarding early with cloud_waitlist_email set)
Desktop unchanged — its platform shell doesn't pass cliInstructions,
so OnboardingFlow routes it straight to StepRuntimeConnect for the
bundled-daemon auto-connect path.
Rename step-runtime.tsx → step-runtime-connect.tsx to reflect its
new single responsibility (connect UI only; platform choice lives
in StepPlatformFork).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): capture optional use-case on cloud waitlist
Adds a textarea to the waitlist form asking what the user wants to
use Multica for. Optional (submit still works with email alone) but
surfaces a clear prompt + placeholder example so most users will fill
it in. Stored as cloud_waitlist_description alongside the email.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): make !hasOnboarded a first-class gate on both platforms
Triggering condition was wrong on both sides. Web's dashboard-guard
only checked hasOnboarded when the URL slug failed to resolve; desktop's
App.tsx effect returned early when wsCount > 0 before even looking at
hasOnboarded. Users with existing workspaces never got routed into
onboarding regardless of their flag state.
Also wire store.complete() into the happy-path finish — previously only
the waitlist branch wrote onboarded_at, so every normal completion
left the flag false and (now that triggers work) would loop users back
into onboarding on refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 5 auto-bootstrap — welcome issue + Getting Started project
After agent creation, the flow transitions to a loader screen that
runs the bootstrap in the background:
- Creates a welcome issue with a Q3-driven prompt, assigned to the
new agent (so it starts working immediately)
- Creates a "Getting Started" project with tutorial sub-issues
filtered by Q1/Q2/Q3
- Stores first_issue_id + onboarding_project_id via store.complete()
- Navigates the user straight into the welcome issue detail page,
where they see the agent already responding
Degraded path: if welcome issue fails, shows error with Retry /
Continue anyway. If project or sub-issues fail, logs and proceeds
with just the welcome issue — the aha moment still happens.
No-agent paths (runtime skip, agent skip) short-circuit to onComplete
without bootstrap.
Local flow step union now aligns with the store enum; removed the
mapLocalToStoreStep bridge and deleted the old step-complete.tsx
placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): converge all no-agent paths to a single bootstrap step
Before: skip-runtime, skip-agent, and waitlist each finished onboarding
independently, bypassing Step 5 entirely. Users without an agent landed
in an empty workspace with no tutorial project — the "self-serve" case
had no bootstrap at all.
Now: all three paths converge on the first_issue step with agent=null.
Bootstrap branches on agent presence:
- agent ✓ → welcome issue (assigned to agent) + project + agent-guided
sub-issues ("watch your agent do X"). Lands on the welcome issue.
- agent ✗ → project only + self-serve sub-issues ("try X yourself" —
configure runtime, create agent, write first issue, etc.). Lands on
the workspace issues list with the Getting Started project in the
sidebar.
Both web and desktop shells already handle firstIssueId=undefined →
fall back to /<slug>/issues, so no shell-side change was needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): pin starter project + assign sub-issues to the user
Bootstrap now also:
- Pins the Getting Started project so users see it in the sidebar
immediately (both paths)
- Pins the welcome issue too (path A only) so the first conversation
with the agent stays one click away
- Assigns every sub-issue to the current user (via their workspace
member record). Only the welcome issue stays assigned to the agent —
that's the aha-moment hand-off; everything else is for the user to
work through
Pin calls are fire-and-forget (failure logged but non-blocking).
Member lookup is defensive — if listMembers fails or the user isn't
found, sub-issues gracefully fall back to unassigned rather than
breaking the bootstrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): remove cloud waitlist option
Cloud runtime is not on the immediate roadmap and there's no backend
table to persist emails. Keeping the UI around would silently drop
user submissions — small trust leak. Revisit once cloud product lands
alongside a proper waitlist table + notification pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): persist onboarded_at end-to-end
Phase 1 of bringing onboarding from dev stub to production. A single
persisted column drives every trigger — no separate user_onboarding
table yet (that's a later phase for questionnaire persistence, cloud
waitlist, analytics).
Backend
- Migration 050: ALTER TABLE "user" ADD COLUMN onboarded_at TIMESTAMPTZ
(no backfill — existing users see onboarding next login, Skip
affordance lands later)
- sqlc: MarkUserOnboarded with COALESCE for idempotency
- UserResponse DTO + userToResponse now emit onboarded_at via
existing util.TimestampToPtr helper — single edit covers GetMe,
VerifyCode, GoogleLogin, LoginWithToken
- New handler POST /api/me/onboarding/complete
- Route registered in the authenticated user-scoped group
Frontend
- User type gets onboarded_at: string | null
- api.markOnboardingComplete()
- Auth store adds refreshMe() — lightweight getMe + setUser,
complements existing initialize()
- useHasOnboarded switches source from onboarding-store (dev stub)
to auth-store (user.onboarded_at). Every call site — dashboard
guard, desktop App.tsx, invite page fallback, realtime
workspace-loss handler, settings leave/delete — picks up the
real signal without any direct change
- onboarding-store.complete() now hits the server: POST + refreshMe
before local state update, so the next router effect sees the
non-null timestamp and won't bounce the user back
Triggers + route guards
- StepWorkspace drops the Skip button — every onboarding user
must create their own workspace even if invited into one
- /onboarding page redirects already-onboarded users away (guards
against manual URL access)
- login page + auth callback: onboarding wins over ?next= for
unonboarded users; invite links are revisitable after onboarding
Tests
- apps/web callback tests updated: mocks now return User objects
so onboarded_at is readable; new "onboarded user honors next"
scenario added, "unonboarded ignores next" scenario kept
- test/helpers mockUser gets onboarded_at field
- questionnaire already-existing strict-required tests bundled in
from a prior uncommitted change
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): review findings — dead state, error recovery, cache races
From independent review of the prior onboarded_at commit.
- Remove the dead OnboardingState.onboarded_at field, its INITIAL_STATE
entry, and its write in store.complete(). useHasOnboarded now reads
auth-store exclusively; leaving a parallel field here violates the
"don't duplicate server data in Zustand" rule and risks drifting into
a second source of truth.
- Wrap handleBootstrapDone/handleBootstrapSkip in try/catch with toast
recovery. complete() is idempotent server-side (COALESCE), so a
retry after a failed POST/refreshMe is free — letting the error
bubble into the React error boundary trapped the user with no way
forward.
- RedirectIfAuthenticated: swap `!list` for `isFetched`-gated check,
matching the pattern added on the /onboarding page. Same one-tick
race where a stale cache [] could fire a premature replace before
the fresh list settles.
- (Self-review fixups picked up along the way) /onboarding page now
waits for workspacesFetched before redirecting already-onboarded
users, and login handleSuccess reads useAuthStore.getState() so the
hasOnboarded value is fresh after setUser (the closure captured a
stale pre-login value otherwise).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): shrink store surface + firm up flow invariants
Post-review cleanup. End-to-end flow is already complete (user.onboarded_at
is the single source of truth); these are quality-of-life fixes on top.
Store surface
- Drop six dead fields from OnboardingState (workspace_id, runtime_id,
agent_id, first_issue_id, onboarding_project_id, platform_preference)
and the PlatformPreference type. None had readers — they were stub
placeholders for a future user_onboarding table that isn't coming
this phase. CLAUDE.md "don't design for hypothetical future".
- store.complete() signature simplifies to () — no more patch arg,
since the only patch fields were the ones just deleted.
Welcome as a first-class step
- Add "welcome" to OnboardingStep enum and make it INITIAL_STATE's
current_step. Removes the pristine-heuristic "did user see welcome?"
check, which could misfire on remount.
- pickInitialStep() collapses to `state.current_step ?? "welcome"`.
- ONBOARDING_STEP_ORDER stays unchanged (welcome isn't a progress point).
advance() chain
- Every transition handler now persists the new current_step to the
store (handleWorkspaceCreated, handleRuntimeNext, handleAgentCreated,
handleAgentSkip). Refresh lands on the right step instead of
jumping back to Step 2.
Invariants
- OnboardingFlow throws on null user instead of spreading defensive
`?? ""` and `if (userId)` that silently degraded to unassigned
sub-issues. Shell guards already ensure user is present.
- Desktop WindowOverlay's onComplete gains a paths.root() fallback
when workspace is undefined — matches web's symmetry.
docs/product-overview.md: committed from untracked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): persist questionnaire + current_step; resume + Back
End-to-end questionnaire persistence + resume capability. User answers
are now server-side (analytics-ready); refreshing or revisiting lands
on the furthest reached step with previous answers pre-filled; a Back
button on each step lets users edit earlier answers without losing
progress.
Backend
- Migration 051: ALTER TABLE "user" ADD onboarding_current_step TEXT,
onboarding_questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb
- sqlc: new PatchUserOnboarding with sqlc.narg for optional fields
(COALESCE preserves unspecified columns). MarkUserOnboarded also
clears current_step — once complete, the step pointer has no meaning
- Handler PATCH /api/me/onboarding accepting partial {current_step,
questionnaire}. Questionnaire passthrough via json.RawMessage, no
server-side validation of inner shape (keeps schema evolution free)
- UserResponse DTO emits both new fields; userToResponse coalesces
JSONB to '{}' defensively
Frontend
- User type gains onboarding_current_step + onboarding_questionnaire
- api.patchOnboarding(payload)
- Delete Zustand onboarding store — replaced with plain async
advanceOnboarding() / completeOnboarding() that call the API and
sync auth store. Source of truth is the user object, no client-side
shadow state that could drift
- pickInitialStep reads user.onboarding_current_step; StepQuestionnaire
initial pre-fills from user.onboarding_questionnaire
- Monotonic furthestStepRef: Back edits don't regress server-side
progress, and re-submit returns the user to where they were
- Back buttons on Steps 2/3/4. Back is local-only — just changes the
rendered step, no PATCH
- Loading indicator on Welcome + Questionnaire submit buttons while
PATCH is in flight
- CreateWorkspaceForm.onSuccess accepts Promise<void> so the flow can
await advance() from its onCreated handler
Test mocks (helpers + callback test) updated with new User fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): resume to Step 3+ needs workspace/runtime fallback
Self-review caught: resume lands the user on their saved step, but
React state (workspace, runtime, agent) is empty on fresh mount. The
render conditions gate on those — without fallbacks the page stays
blank.
- workspaceListOptions() query fills runtimeWorkspace from cache when
stepping past Step 2. Only one workspace exists during onboarding
(StepWorkspace always creates one), so [0] is unambiguous.
- StepWorkspace accepts an `existing` prop. On resume / Back to Step 2
with a pre-existing workspace, render a "Continue with <name>"
confirmation instead of the create form, which would otherwise hit a
slug conflict the moment the user clicks Create.
- runtimeListOptions(wsId, "me") similarly seeds Step 4's runtime —
prefer first online, fall back to first.
Step 5 resume path unchanged: if `agent` React state is null on
re-entry, bootstrap runs the self-serve branch. Not ideal (user may
have actually created an agent), but bootstrap's list-check approach
(future work) will handle orphan detection symmetrically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): delete all skip/resume jump logic
Flow always starts from Welcome. Questionnaire answers still pre-fill
from user.onboarding_questionnaire. current_step is still PATCHed for
future analytics but no UI code reads it for navigation.
Removed from onboarding-flow.tsx:
- pickInitialStep + isOnboardingStep (no server-driven entry point)
- furthestStepRef + resolveNextStep (no edit-vs-first-pass branching)
- runtimes useQuery + stepRuntime fallback (user walks through Step 3
linearly, so runtime React state is always populated by Step 4)
- workspace resume fallback in runtimeWorkspace (same reasoning)
Kept:
- advanceOnboarding({ current_step, questionnaire? }) — server
persistence, analytics-ready
- StepQuestionnaire's initial prop from stored answers
- workspaces useQuery (gated to step === "workspace" only) for
existing-workspace detection on Step 2 to prevent slug conflicts
when a previous onboarding was abandoned
- Back buttons + handleBack (local-only navigation)
- Error recovery on completeOnboarding via try/catch + toast
Every transition handler is now a straight advance + setStep line.
Users who close mid-flow and return walk the full flow from Welcome
again — slight extra clicks, but each step shows meaningful confirm
UI (existing workspace, connected runtimes, etc.) so it doesn't feel
like repeated work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): grandfather existing users in the onboarded_at migration
Folded the backfill into 050 itself (branch has not shipped to prod,
so editing the migration in place is clean). Without this, once this
branch deploys, every pre-existing user would be walled off into
onboarding on their next login — a real production incident.
Uses created_at rather than NOW() so analytics like "signup →
onboarded interval" read correctly for pre-launch users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 1 questionnaire — two-column editorial layout
Matches the onboarding(3) design spec: full-bleed two-column on lg+
(main + "Why we ask" side rail), collapses to single column below.
- StepQuestionnaire rewritten with:
- Mono 01/02/03 markers per question
- Serif question headings (22px)
- Editorial serif title ("Three answers. We'll handle the rest.")
- Right-side rationale panel explaining what each answer unlocks
- Sticky footer with hint + Continue CTA
- Embeds StepHeader on the left column so it escapes the flow's
narrow max-w-xl wrapper, same pattern Welcome uses
- OptionCard redesigned: radio-dot marker + inset ring on select,
matches design's .opt pattern
- OtherOptionCard: text input appears below the row (not inside the
card) with bottom-border-only styling, aligned under the label
- onboarding-flow: questionnaire now early-returns full-bleed,
joining Welcome as a hero-layout step
Placeholder copy updated to match design examples; tests adjusted.
* fix(onboarding): questionnaire uses 3-region app-shell layout
Previous version had everything in a single scroll container with a
sticky footer. As the user scrolled into the questions, the Back
button and StepHeader progress indicator scrolled out of view, and
sticky-bottom had edge cases with width-constrained flex nesting.
Classic 3-region shell now:
- Fixed header row: Back button (left) + StepHeader progress
indicator — persistently visible regardless of scroll position
- Scrollable middle: eyebrow / serif title / lede / 3 question
blocks. Uses `flex-1 overflow-y-auto min-h-0` — the min-h-0 is
the critical bit that lets a flex-1 child shrink below content
height inside a flex column
- Fixed footer row: hint (hidden < sm) + Continue CTA — always
reachable, never scrolled off
Right "Why we ask" panel is now an independent grid column with its
own overflow, so the two columns scroll independently instead of the
whole page having one shared scrollbar.
Side panel width reduced 520 → 480 to give the question column more
room on 1280/1366 screens where 1fr_520 left ~760px for content;
1fr_480 gives ~800-900px which comfortably fits the 620px max-w
content column plus breathing room.
* fix(onboarding): questionnaire needs DragStrip like every full-window view
Traffic lights were overlapping the StepHeader progress dots because
Step 1 escaped onboarding-flow's non-welcome wrapper (which renders
<DragStrip />) without rendering its own. The codebase convention per
packages/views/platform/drag-strip.tsx is: every full-window view
places a DragStrip as the first flex child of each visible column.
Adds DragStrip at the top of both the left (shell) and right
("Why we ask") columns, matching step-welcome.tsx which already did
this. Traffic lights now land in the 48px transparent strip with no
content collision; dragging from any top edge moves the window on
Electron; border-l between columns runs edge-to-edge.
Also made the right column's scroll container use
`min-h-0 flex-1 overflow-y-auto` so its internal scroll activates
independently of the left column.
(Separately investigated: useImmersiveMode is no longer called
anywhere in production code — the codebase has fully committed to
the DragStrip pattern. No action needed on the hook itself.)
* style(onboarding): drop top/bottom borders on questionnaire shell
* style(onboarding): use chat-style scroll fade mask instead of border
The questionnaire's scroll area now fades softly at top/bottom edges
via `useScrollFade` (already used by chat-message-list.tsx) — the
same mask-image linear-gradient pattern that fades content under the
header/footer based on scroll position:
- At top: only bottom fades (hint: more content below)
- At bottom: only top fades (hint: content above)
- In middle: both fade
- Fits entirely: no mask
This replaces the removed border-b/border-t on the header/footer with
a softer, more editorial visual separation while giving an actual
scroll-position affordance the border can't.
* feat(onboarding): show "n of 3 answered" progress next to Continue
Gives the user a glance-able progress signal as they fill the
questionnaire. Static text, no extra UI primitives, no dynamic
state variants — just `{n} of 3 answered` updating in place,
left of the Continue button.
Replaces the static "Your answers shape the next screens..." hint,
which was always there regardless of progress and added noise.
Same canContinue gate as before (all 3 answered), just derived
from the new per-question check so we don't compute validity twice.
* style(onboarding): drop redundant lede under questionnaire title
The title already conveys the "we'll handle the rest for you"
promise — the lede just rephrased it at length. Removed; bumped the
question-list top margin (mt-8 → mt-10) to keep breathing room.
* feat(onboarding): land redesigned flow + post-landing starter content opt-in
This commit bundles the final onboarding-redesign work that sat in the
working tree with today's architectural reshape of how starter content
is handled. Splitting across sqlc-regenerated files would be fragile,
so it ships as one logical unit — "onboarding is ready for production".
Flow redesign (Steps 1–5)
-------------------------
- Editorial two-column shells on Steps 1/2/3/4 (DragStrip + hero column
+ aside panel) — Welcome, Questionnaire, Workspace, Runtime, Agent
- Web-only Step 3 fork (Download desktop / Install CLI / Cloud waitlist)
lives alongside desktop's direct runtime picker; cloud path is
interest-capture only, doesn't advance the flow
- DragStrip extracted to packages/views/platform as a cross-platform
component — 48px transparent drag row, no-op on web
- recommend-template.ts + test: Q1–Q3 → AgentTemplate mapping
Cloud waitlist
--------------
- Migration 052: cloud_waitlist_email VARCHAR(254) + cloud_waitlist_reason TEXT
- Handler: net/mail.ParseAddress + length bounds + reason trim
- Frontend: CloudWaitlistExpand component + api.joinCloudWaitlist
Drop persisted onboarding_current_step
--------------------------------------
- The interim implementation persisted the user's furthest-reached step;
the final design starts every entry at Welcome, so the column is dead
- Migration 051 no longer adds it; migration 053 drops it IF EXISTS on
any environment that ran the interim 051 — schema converges cleanly
- UserResponse / User type / patchOnboarding signature all drop the field
Post-landing starter content (new architecture)
-----------------------------------------------
Why: the old design ran bootstrap inside Step 5 (welcome issue + Getting
Started project + sub-issues, all in one try block). That had three
defects — (1) non-idempotent: Retry after partial failure created
duplicates; (2) sub-issue assignee raced listMembers → showed as
"Unknown"; (3) skipped users (paths A/C/D) never got any starter
content. All three are structural, not patchable.
New design: onboarding ends at completeOnboarding() as before (gate is
unchanged for useDashboardGuard). The 4 completion paths (Welcome skip
/ full flow / Runtime skip / Error recover) all just call
completeOnboarding() and navigate to workspace. On landing, a
StarterContentPrompt dialog renders exactly once per user
(starter_content_state == null) with Import / No thanks. The dialog is
mandatory — no X, no ESC, no outside-click — so state always ends in a
terminal value.
- Migration 054: starter_content_state TEXT, backfill 'skipped_legacy'
for pre-feature onboarded users so they're never prompted
- Server POST /api/me/starter-content/import: transactional claim
(NULL → 'imported') + bulk create project + optional welcome issue +
sub-issues + pins, all in one tx. 409 Conflict on second call
- Server POST /api/me/starter-content/dismiss: transactional NULL → 'dismissed'
- Import decides agent-guided vs self-serve by inspecting the workspace's
agent list at dialog time — fixes path A (Welcome skip + existing
agent) which was previously excluded from starter content
- starter-content-templates.ts replaces bootstrap.ts: pure template
builders, no API calls. Copy is reviewed as UI; server owns atomicity
- StepFirstIssue is now just completeOnboarding() + navigate; error
surface collapses to a Retry button (no more "Continue anyway" branch)
- OnboardingCelebration + just-completed.ts removed (replaced by
StarterContentPrompt which reads server state, not sessionStorage)
Handler hardening
-----------------
- PatchOnboarding: MaxBytesReader 16KB so the JSONB column can't be
weaponized as bulk storage (every /api/me read returns the payload)
- JoinCloudWaitlist: net/mail format check + explicit 254-char cap
- ImportStarterContent: MaxBytesReader 64KB (templates are markdown-heavy
but still bounded); welcome issue's agent_id verified in-workspace
Tests
-----
- Existing onboarding_test.go (waitlist) passes
- step-platform-fork.test.tsx + recommend-template.test.ts (new)
- apps/web test helpers updated for User.starter_content_state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onboarding): resolve Unknown assignee/creator + tighten prompt copy
Two surface issues on the post-landing starter content dialog:
1. Unknown assignee & Created by
-------------------------------
ImportStarterContent stored `member.id` (the membership row UUID) in
`assignee_id` and `creator_id` for sub-issues. That mismatched the rest
of the codebase — AssigneePicker and resolveActor in issue.go both
store `user_id` for type="member", and `useActorName.getMemberName`
looks members up by `user_id`. The mismatch meant the lookup never
matched any member and fell through to the "Unknown" fallback.
Fix: use `parseUUID(userID)` for both fields. The existing membership
check stays for the 403 signal; we just no longer need the returned
`member.ID`.
2. Dialog copy too long, button labels unclear
----------------------------------------------
Old copy was 3–4 paragraphs of instruction; users need to read less
than that to make a binary choice. Buttons "Import starter tasks" and
"No thanks" also didn't make it clear what "No thanks" actually does —
it starts a blank workspace, so say so.
New:
- Title: "Welcome — add starter tasks?"
- Body: one sentence describing the seeded content
- Left button: "Start blank workspace"
- Right button: "Add starter tasks"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(onboarding): server decides starter content branch
Problem: the old ImportStarterContent gated the agent-guided vs
self-serve branch on a client-supplied `welcome_issue.agent_id` or
null `welcome_issue`. The client made that decision by reading its
React Query cache of the workspace's agent list — any timing quirk
(cache not populated, stale, race with WS event) could lie to the
server, and there was no way for the server to disagree. Users with
an agent in the DB could still end up on the self-serve branch.
Fix: the server is now authoritative. The client always sends both
template arrays (agent_guided_sub_issues, self_serve_sub_issues) and
a welcome_issue_template (title + description + priority, NO agent_id).
Inside the import transaction the server runs ListAgents on the
workspace — if there's at least one agent, it picks agents[0] (same
ordering the client used: created_at ASC), uses agent_guided_sub_issues,
and creates the welcome issue assigned to that agent. Otherwise it
uses self_serve_sub_issues and skips the welcome issue.
Side effect: the Unknown assignee/creator bug is structurally gone —
no client-supplied id flows into assignee_id/creator_id for type=
"member". The server uses actorID = parseUUID(userID) everywhere,
matching resolveActor in issue.go.
Client surface also simplifies: StarterContentPrompt drops
useQuery(agentListOptions), the hasAgent check, the agentsFetched
button gate, and the branch-specific copy. Dialog description is a
single generic line ("If you already have an agent, we'll also seed
a welcome issue it replies to right away"). buildImportPayload no
longer takes an agentId parameter — one unconditional return shape.
Payload grows ~15 KB (both sub-issue arrays always present); still
well under the 64 KB MaxBytesReader cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): clarify runtime prerequisite, revert dialog agent list
Step 3 runtime (desktop step-runtime-connect.tsx) — scanning and empty
subtitles now name the local AI coding tools Multica drives (Claude
Code, Codex, Cursor, and others), so users understand a runtime alone
isn't enough: they also need one of those tools installed on the
machine. Uses "and others" rather than a closed list so we don't lock
the copy to exactly three integrations.
StarterContentPrompt dialog — reverted the short-lived "try Coding,
Planning, Writing agents and more" rewrite. That was a misread of
feedback meant for the Step 3 prerequisite, not the dialog. The
dialog's current single-sentence "how agents, issues, and context
work in Multica" is enough.
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(analytics): add PostHog client with async batch shipping
Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.
Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.
* feat(server): emit signup and workspace_created analytics events
Wires analytics.Client through handler.New and main, then emits the first
two funnel events:
- signup fires from findOrCreateUser (which now reports isNew), covering
both the verification-code and Google OAuth entry points — a single
emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
with is_first_workspace computed from a post-commit ListWorkspaces count
so we can distinguish fresh-user activation from returning-user
expansion.
Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.
* refactor(analytics): drop is_first_workspace from workspace_created
Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.
PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.
* docs(analytics): document self-host safety defaults
Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.
* feat(analytics): emit runtime_registered, issue_executed, team_invite_*
Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:
- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
first_executed_at IS NULL flips the column for the first time. Retries,
re-assignments, and comment-triggered follow-up tasks hit the WHERE
clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
from AcceptInvitation, closing the expansion funnel.
Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.
* feat(config): surface PostHog key via /api/config
Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.
* feat(analytics): wire posthog-js identify + UTM capture on the client
Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.
captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.
Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
cookie and token paths. A getConfig/getMe race is handled by buffering
a pending identify inside the analytics module and flushing it once
initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
loginWithToken and resetAnalytics() on logout so the next login merges
cleanly without bleeding events.
* docs(analytics): describe runtime_registered, issue_executed, invite events
Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.
* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed
Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.
Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.
* fix(analytics): wire $pageview and harden signup_source cookie payload
Two frontend fixes from the PR review:
- PageviewTracker, mounted under WebProviders, fires capturePageview on
every Next.js App Router path / query-string change. Without this the
capturePageview helper in @multica/core/analytics was never called and
the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
*before* JSON.stringify, and drops the whole cookie when the serialised
payload still exceeds 512 chars. Previously the overall slice(0, 256)
could leave a half-JSON string on the wire that neither the backend nor
PostHog could parse.
Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.
* fix(analytics): URL-decode signup_source cookie on read
Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.
* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding
Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.
---------
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
* Reapply "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit 9b94914bc8.
* compat: legacy URL redirect + localStorage double-write for safe rollback
The first attempt at this refactor (#1131) was reverted because existing
users on old URLs (/issues, /projects, etc.) hit 404 immediately after
deploy, and rolling back left them with empty dashboards — the legacy
code reads localStorage["multica_workspace_id"] to attach a workspace
to API requests, but the new code had stopped writing that key.
Two compat layers added on top of the restored refactor:
1. proxy.ts now intercepts legacy route prefixes (/issues/*, /projects/*,
/agents/*, /inbox/*, /my-issues/*, /autopilots/*, /runtimes/*,
/skills/*, /settings/*). Logged-in users with a last_workspace_slug
cookie are 302'd to /{slug}/{rest}, preserving their deep link. Users
without the cookie bounce through / where the landing page picks a
workspace client-side. Unauthenticated users go to /login.
2. Both layouts now double-write the workspace id to the legacy
localStorage key on every workspace entry. New code ignores this key
— it exists solely so that if this PR ever gets reverted again, the
legacy build reading the key would still find the correct workspace
and avoid the empty-dashboard symptom users saw during the rollback.
Net effect: any direction of deploy ↔ rollback is now cache-compatible,
and any direction of old bookmark → new route resolves without 404.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(platform): defer rehydrateAllWorkspaceStores to a microtask
Same React 19 render-phase restriction that forced setCurrentWorkspace
to defer its subscriber notifications. rehydrateAllWorkspaceStores
synchronously calls each persist store's rehydrate, which setState()s
the store, which schedules updates on any subscribed component. When
the workspace layout's render-phase ref guard invoked this, React
complained that SearchCommand (a store subscriber) couldn't be
re-rendered while WorkspaceLayout was still rendering.
Fix: queueMicrotask the rehydrate loop and add a pending-flag guard so
rapid workspace switches coalesce into one rehydrate on the final slug.
Persist stores tolerate one microtask of staleness — they hold UI
preferences, not correctness-critical state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: workspace URL refactor + slug-first API identity
Make the URL the single source of truth for workspace identity.
All workspace-scoped URLs now carry the workspace slug as the first
path segment (/{slug}/issues, /{slug}/projects, etc.), matching the
industry standard (Linear, Notion, Vercel, GitHub).
## Key architectural changes
**URL-driven workspace identity:**
- Web routes moved under app/[workspaceSlug]/(dashboard)/
- Desktop routes nested under /:workspaceSlug
- paths.ts builder centralises all URL construction
- reserved-slugs validation (backend + frontend + DB migration audit)
**Slug-first API contract:**
- Frontend sends X-Workspace-Slug header (from URL) instead of X-Workspace-ID (UUID)
- Backend middleware resolves slug → UUID via GetWorkspaceBySlug, falls back to
X-Workspace-ID for CLI/daemon backwards compatibility
- WebSocket auth accepts ?workspace_slug query param with SlugResolver callback
**State cleanup:**
- Deleted: useWorkspaceStore (Zustand mirror), switchWorkspace/hydrateWorkspace/
clearWorkspace, localStorage["multica_workspace_id"], api._workspaceId
- useCurrentWorkspace() derives from URL slug + React Query workspace list
- useWorkspaceId() is now a bridge hook (no Context, derives from useCurrentWorkspace)
- WorkspaceIdProvider removed from DashboardGuard
- Paired module vars (slug + UUID) in workspace-storage.ts for non-React consumers
**Layout simplified:**
- Render-phase ref guard sets workspace context synchronously (no async gate)
- DashboardGuard handles auth redirect, loading state, and workspace resolution
- Subscriber notifications deferred via queueMicrotask (React 19 compat)
- persist namespace uses slug (immutable) instead of UUID
## Issues resolved
MUL-43 (share links), MUL-509 (mobile workspace switch), MUL-723 (workspace in URL),
MUL-727 (create workspace flash), MUL-728 (delete workspace no-navigate),
MUL-820 (sidebar Join not switching)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve code review C3/C4/C5/C6 — desktop deadlock + hardcoded paths
C3: Desktop OnboardingGate was calling useCurrentWorkspace() outside
WorkspaceSlugProvider → always null → permanent onboarding deadlock.
Rewrite to use useQuery(workspaceListOptions()) which reads React Query
cache directly without slug context. Remove DashboardGuard from
DesktopShell (auth gating handled by AppContent, workspace routing by
WorkspaceRouteLayout per-tab).
C4: Landing page "Dashboard" links hardcoded /issues (no longer valid).
Changed to / — proxy handles redirect to /{lastSlug}/issues.
C5: autopilots-page.tsx had one hardcoded /autopilots/${id} link.
Changed to wsPaths.autopilotDetail(id).
C6: inbox-page.tsx hardcoded /inbox paths. Changed to wsPaths.inbox().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): wrap shell in WorkspaceSlugProvider from module var
AppSidebar calls useWorkspacePaths() → useRequiredWorkspaceSlug() which
throws outside WorkspaceSlugProvider. In the desktop shell, the sidebar
renders at the shell level (outside any tab's WorkspaceRouteLayout).
Fix: DesktopShell reads the current slug via useSyncExternalStore on
the workspace-storage singleton. When slug is available, wraps the
entire shell in WorkspaceSlugProvider. When null (first mount before
any tab's WorkspaceRouteLayout sets it), shows a loading spinner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): migrate old tab paths + fix shell slug deadlock
Tab store rehydration: old-format paths like "/issues/abc" (missing
workspace slug prefix) are reset to "/" so IndexRedirect picks the
correct workspace. Detection: if the first segment is a known route
name (issues, projects, etc.) rather than a workspace slug, it's an
old-format path.
Desktop shell: TabContent must always render (not gated behind slug
check) so WorkspaceRouteLayout can mount and call setCurrentWorkspace.
Only sidebar and shell-level UI (chat, modals, search) gate on slug
being present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add GET /api/config endpoint exposing cdn_domain from CLOUDFRONT_DOMAIN
- Create packages/core/config/ zustand store, fetched at app startup
- Extract file card preprocessing to packages/ui/markdown/file-cards.ts
with isCdnUrl(url, cdnDomain) using exact hostname match
- Add file card support to packages/ui/markdown/Markdown.tsx (was missing)
- Remove hardcoded .copilothub.ai hostname check from file-card.tsx
- Fix LocalStorage.CdnDomain() to return hostname not full URL
- Always run preprocessFileCards regardless of cdnDomain availability
(!file syntax works without CDN domain, only legacy matching needs it)
- Use useConfigStore hook in common/markdown.tsx for reactive updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(autopilot): add scheduled/triggered automation for AI agents
Introduce the Autopilot feature — recurring automations that assign work
to AI agents on a schedule or manual trigger. Supports two execution
modes: create_issue (creates an issue for the agent to work on) and
run_only (directly enqueues an agent task without issue pollution).
Backend: migration (3 tables + 2 columns), sqlc queries, AutopilotService
with concurrency policies (skip/queue/replace), HTTP CRUD + trigger
endpoints, background cron scheduler (30s tick), event listeners for
issue→run and task→run status sync.
Frontend: types, API client methods, TanStack Query hooks with optimistic
mutations, realtime cache invalidation, list page with create dialog,
detail page with trigger management and run history, sidebar nav + routes
for both web and desktop apps.
* feat(autopilot): improve UX — trigger config, edit dialog, template gallery
- Replace raw cron input with friendly frequency tabs (Hourly/Daily/Weekdays/Weekly/Custom), time picker, and timezone dropdown defaulting to user's local timezone
- Fix Select components showing UUIDs instead of names (Base UI render function pattern)
- Add Edit button on detail page opening a unified edit dialog
- Remove project/concurrency/issue-title-template from create/edit (simplify for users)
- Add trigger configuration inline during autopilot creation
- Add template gallery on empty state (6 step-by-step workflow templates)
- Rename "Description" to "Prompt" throughout UI
- Inject autopilot run timestamp into issue description for agent date awareness
- Treat issue status "in_review" as run completion (fixes skip on next trigger)
- Make migration idempotent with IF NOT EXISTS clauses
* feat: add pin to sidebar for issues and projects
Add per-user pinning of issues and projects to the sidebar for quick access.
- New `pinned_item` table with per-user, per-workspace scoping
- REST API: GET/POST /api/pins, DELETE /api/pins/{type}/{id}, PUT /api/pins/reorder
- Sidebar "Pinned" section between Personal and Workspace nav (hidden when empty)
- Pin/unpin actions in issue and project detail dropdown menus
- Optimistic mutations with WebSocket invalidation for real-time sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add drag-and-drop reordering and visible pin buttons
- Sidebar pinned items now support drag-and-drop reordering via @dnd-kit
- Add visible pin/unpin icon button in issue and project detail headers
- Add useReorderPins mutation with optimistic updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove drag handle and fix page refresh after reorder
- Remove GripVertical drag handle — whole item is now draggable, aligning
with other sidebar elements
- Prevent link navigation after drag using wasDragged ref
- Remove onSettled invalidation from reorder mutation to prevent
unnecessary refetch after optimistic update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Document wsId/header coupling in chat queries (cache key vs API call)
- Extract finalizePending helper to reduce duplication across 4 WS handlers
- Store chat store handle in module-level variable for consistency with
auth/workspace stores in CoreProvider
- Remove redundant ./chat/store package export (covered by ./chat barrel)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move chat queries, mutations, and store from apps/web/core/chat/ and
apps/web/features/chat/store.ts to packages/core/chat/. Refactor store
to use createChatStore({ storage }) factory pattern (mirrors auth store)
so it works in both web (localStorage) and desktop (Electron) without
direct browser API access. Register chat store in CoreProvider.initCore.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add vitest configs to packages/core and packages/views. Test deps
added to pnpm catalog for unified versioning. Web test deps migrated
to catalog references. pnpm test now discovers all packages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add @multica/eslint-config package (base, react, next configs)
- Replace `next lint` (removed in Next.js 16) with `eslint .`
- Add lint scripts to all packages and desktop app
- Add noUnusedLocals, noUnusedParameters, noImplicitReturns to base tsconfig
- Fix all resulting TS/ESLint errors (unused imports, missing returns,
stale eslint-disable comments from legacy eslint-config-next)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prepare for merge by integrating main's new features into the
extracted shared packages architecture:
- Chat feature (ChatFab, ChatWindow) added to web dashboard extra slot
- Sidebar redesign (3-group nav, search slot, user footer, runtime updates)
- WorkspaceIdProvider moved outside SidebarInset for extra components
- Social links, twitter metadata, showDevtools, latestCliVersion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extract MulticaIcon and ThemeProvider to packages/ui (remove duplication)
- Extract shared CSS (scrollbar, shiki, entrance-spin) to packages/ui/styles/base.css
- Add NavigationAdapter.openInNewTab/getShareableUrl for platform-agnostic navigation
- Fix window.open() / window.location.href in shared views to use NavigationAdapter
- Add resolve.dedupe for React in electron-vite config
- Fix desktop tsconfig (noImplicitAny: true)
- Use catalog: for all desktop dependencies
- Add shadcn + tw-animate-css to desktop dependencies (fix phantom deps)
- Add typecheck scripts to all shared packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(runtime): proactive CLI update notifications with per-user filtering
- Add latestCliVersionOptions query (GitHub Releases API, 10-min TanStack cache)
- Add useMyRuntimesNeedUpdate / useUpdatableRuntimeIds hooks using owner_id
- Show red dot on sidebar Runtimes item when user's runtimes need updates
- Show update arrow icon alongside status dot in runtime list items
* fix(core): add runtimes/hooks to package.json exports
P0: Replace all localStorage calls in packages/core with StorageAdapter
- Create StorageAdapter interface (getItem/setItem/removeItem)
- Auth store factory now requires storage parameter
- Workspace store factory accepts optional storage parameter
- WSProvider accepts storage prop for token retrieval
- apps/web/platform/ passes localStorage as the web implementation
P1: Remove sonner UI dependency from packages/core
- Replace toast.error() in workspace store with onError callback
- Move sonner import to apps/web/platform/workspace.ts
- Remove sonner from packages/core/package.json dependencies
P2: Delete 5 pure re-export barrel files in apps/web/features/
- features/issues/index.ts, modals/index.ts, navigation/index.ts,
workspace/index.ts, inbox/index.ts — all had zero consumers
- features/ now only contains auth/ (web-only cookie + initializer)
and landing/ (web-only pages)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>