Commit Graph

1501 Commits

Author SHA1 Message Date
Multica Eve
6045a5dc80 fix(runtimes): use official Qwen Code logo (#5713)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-21 15:34:44 +08:00
Bowser
f8bf6cd8b9 feat(runtime): add Qwen Code runtime (MUL-5015)
Merge approved PR #5666.
2026-07-21 14:55:08 +08:00
Naiyuan Qing
5a11232c47 feat(rich-content): unify Chat and Issue/Comment on one RichContent renderer (MUL-4922) (#5578)
* refactor(markdown): single canonical sanitize schema for both renderers (MUL-4922)

Phase 1 of the RichContent convergence: collapse the duplicated security
base shared by the two product-level Markdown chains.

Chat (packages/ui/markdown/Markdown.tsx) and Issue/Comment
(packages/views/editor/readonly-content.tsx) each carried a verbatim fork
of the rehype-sanitize schema and urlTransform, and the forks had already
drifted: readonly whitelisted <mark> for `==highlight==`, chat did not.
A security-relevant allow-list maintained in two places means every future
XSS fix has to land twice, and missing one is a hole — this is the hardest
reason for the sweep, ahead of the user-visible feature drift.

- Extract markdownSanitizeSchema + markdownUrlTransform into
  packages/ui/markdown/sanitize.ts and export from the package index.
  Both chains now import the single copy; no local forks remain.
- The canonical schema is the union, so chat gains the <mark> tag name.
  This is the one intentional behavior delta: <mark> is inert and admits
  no attributes, and chat needs it anyway once ==highlight== converges.
- Annotate the schema as rehype-sanitize's Options: exporting it makes the
  previously-inferred hast-util-sanitize type unnameable across packages.

Adds a cross-surface contract test that runs one set of security fixtures
(script, event handlers, javascript: href, data:image vs data:text/html,
mark, slash://) through BOTH surfaces and asserts identical outcomes —
the mechanism that stops a third fork from growing back.

Code-block rendering is deliberately not asserted cross-surface yet: chat
highlights with Shiki, readonly with lowlight, so emitted class tokens
still differ. Converging them is the RichCodeBlock phase and needs the
highlight-engine decision first; only the schema-level allow-list is
shared here.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
pnpm vitest run in packages/views (228 files, 2665 tests, all passing).

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

* feat(rich-content): one RichContent renderer for Chat and Issue/Comment (MUL-4922)

Phase 2+3 of the convergence. Chat now renders agent output through the same
product renderer as Issue descriptions and Comments, so a ```mermaid fence an
agent emits is a diagram in Chat — not a dead code block.

Before this there were two product Markdown chains. Issue/Comment had Mermaid,
HTML preview, lowlight code, product Mention/Attachment/LinkHover; Chat went
through a generic renderer with no Mermaid/HTML dispatcher at all. Chat is
where agents emit the most diagrams, so the gap was most visible exactly where
it mattered.

Architecture
- packages/views/rich-content/ holds the ONE product renderer: a single
  ReactMarkdown pipeline, one sanitize config, one components map, one fenced
  -code dispatcher. Public API is content/attachments/density/phase — no
  `surface` prop, no renderMention override, no custom code-renderer hook,
  because each is a door a per-surface fork walks back through.
- `density` is CSS only; `phase` is lifecycle only. Neither switches parser,
  plugins or the semantic DOM, so all surfaces emit the same blocks.
- ReadonlyContent is now a ~40-line compatibility wrapper (was 501 lines);
  its consumers are untouched. views/common/markdown.tsx is deleted.
- Leaf components (Mermaid, HTML preview, lowlight code) stay in editor/ and
  are imported by direct path, so Tiptap keeps reusing them and Chat does not
  pull in the editor's Tiptap graph. Moving them is a later mechanical commit.

Streaming fence gate
Rich blocks upgrade only when the fence is CLOSED, judged by a real CommonMark
parse (mdast) rather than a `startsWith("```")` scan. A half-streamed fence
renders as source, so Mermaid never parses a partial diagram and no iframe is
created for HTML still arriving. Closedness — not `phase` — is the gate, so a
settled-but-malformed fence stays source too. The gate returns offsets only; it
never renders, because a gate that rendered would be the second renderer this
change exists to delete. Verified that source offsets survive rehype-raw +
sanitize + katex before relying on them.

Live -> persisted row identity
The live timeline moved out of Virtuoso's Footer into a real row keyed
`task:<taskId>`, and persisted assistant rows key on the same task instead of
`message.id`. Completion is now an in-place data swap through one
AssistantMessage component, so an already-rendered diagram or iframe stays
mounted and is not re-run. The process fold previously relied on that remount
to re-collapse, so the collapse is now expressed directly.

Notes
- Chat code blocks move from Shiki to lowlight (Howard's ratified choice),
  matching Tiptap/Readonly and the existing hljs CSS. Ligature suppression
  carries over via code.css instead of utility classes.
- Chat message tests now stub react-virtuoso: real Virtuoso renders its Footer
  but no data rows under jsdom's zero-height viewport, and the live timeline is
  a row now.

Tests: five-surface Mermaid parity (Chat user / live / persisted, Issue
description, Comment), streaming open->closed->settled with mount counting,
live->persisted no-remount, htmlbars/mermaidx not misdispatched, sandbox and
data-URI security regressions, CommonMark fence edge cases (shorter closer,
tilde, indented, nested, in-list), and source-level import boundary guards.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 240 files / 2743 tests all passing.

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

* fix(rich-content): drop dangling export, add export guard + lazy rich blocks (MUL-4922)

Two review follow-ups.

1. Dangling package export
`packages/views` still exported "./common/markdown" after the file was
deleted in the RichContent convergence. TypeScript resolves against the
source tree so typecheck and unit tests both passed; the subpath would only
fail when a consuming app bundled it. No consumer imports it, so this was
latent rather than broken in practice.

Removes the entry and adds packages/views/rich-content/package-exports.test.ts,
which walks every workspace package.json and asserts each export target
exists (wildcards checked by directory prefix). Scoped repo-wide because the
failure mode belongs to package.json, not to this package. Verified the guard
actually fails by re-adding the dangling entry before committing.

2. Near-viewport lazy shell for Mermaid / HTML
Implements the deferred performance contract rather than requesting an
exemption. LazyRichBlock defers each rich leaf until it is within 800px of
the viewport, then latches: once mounted it is never unmounted, so scrolling
past does not re-run Mermaid, rebuild the sandboxed iframe, or discard the
viewer's pan/zoom state.

The stable-size requirement is handled by reserving the block's expected
height before AND after mount, so a block never measures 0px off-screen and
then jumps — the churn that makes a virtualized list mis-estimate item sizes.
The reservation is not a local guess: reservedMermaidHeightPx() reuses the
existing session layout cache (real height on a cache hit, else the documented
280px skeleton) and HTML_BLOCK_PREVIEW_HEIGHT_PX is the pixel twin of the
preview iframe's fixed h-[480px]. Both are exported from the leaves so there
is one source of truth per block type.

Environments without IntersectionObserver (jsdom, SSR) mount eagerly, which is
today's behaviour; rendering nothing would not be.

Verified in real Chromium (jsdom has no IntersectionObserver, so unit tests
only exercise the eager fallback):
- Non-virtualized Issue/Comment with 25 diagrams: 3 mounted, 22 deferred on
  load; after scrolling, mounted grows 3 -> 6 and never drops, confirming the
  latch. This is where the win is real.
- Chat gains less: Virtuoso already caps the DOM to ~4 rows, so only 1 of 4
  shells was deferred. Stated rather than overclaimed.
- Live -> persisted handoff re-checked with the shell in place: scrollTop
  220 -> 220 (delta 0), the tagged diagram DOM node survived, and the run
  reached the persisted state. Contract 1 is not regressed.

Tests: 6 lazy-shell cases (defer, mount-on-approach, latch/no-unmount, equal
reserved height before and after mount, root margin exceeding the chat list's
own overscan, no-IntersectionObserver fallback) plus 3 export-guard cases.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 242 files / 2752 tests all passing.

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

* fix(rich-content): SSR-deterministic lazy state, mention a11y, drop type casts (MUL-4922)

Three review blockers.

1. Hydration mismatch in the lazy shell
LazyRichBlock derived its initial `mounted` state from feature detection
inside useState. On the server (no window) that resolved true and rendered
the whole Mermaid/HTML subtree; in the browser (IntersectionObserver present)
the hydration pass resolved false and rendered a placeholder — different
markup for the same component, and an SSR path that silently bypassed the
lazy gate. `"use client"` does not opt a component out of Next's server
render, so this was reachable.

Initial state is now unconditionally false on both sides. The eager fallback
for environments without IntersectionObserver moved into the effect, which
never runs on the server, so the first committed frame is identical
everywhere and the latch is unchanged.

The first version of the SSR test passed against the buggy component: jsdom
always provides `window`, so server and client took the same detection branch
and the mismatch was unobservable. The suite now removes IntersectionObserver
for the duration of renderToString to reproduce the real asymmetry. Verified
by reinstating the bug: 2 of the 3 SSR tests fail and React raises its own
"Hydration failed" error.

2. Project mention lost keyboard access
The unified renderer inherited the readonly surface's `<span onClick>` around
ProjectChip, while Chat had previously used AppLink — so converging the
surfaces regressed Chat from a focusable anchor to a mouse-only span. A span
and an anchor are visually identical and behave the same under a mouse, which
is why only an assertion on the emitted element catches it.

Now rendered through AppLink, which also owns plain-click, modifier-click and
the desktop new-tab adapter, so none of that is reimplemented. The wrapper
keeps only stopPropagation, matching IssueMentionCard.

Adds project-mention-a11y.test.tsx using the REAL AppLink and
NavigationProvider — mocking AppLink to emit an anchor would assert the mock.
Covers anchor + href, chip's nearest interactive ancestor, Tab focus, Enter
activation, click, and modifier-click labelling. All 6 fail on the old span.

3. Type suppressions in the production renderer
`as never` on the plugin lists and `as NonNullable<Components[...]>` on the
code/pre overrides silenced real type errors, against the repo's strict-TS
rule. Replaced with react-markdown's own types: RichCode/RichPre now derive
their props from `ComponentPropsWithoutRef<tag> & ExtraProps`, and the plugin
lists use `satisfies NonNullable<Options["remarkPlugins" | "rehypePlugins"]>`
so a bad plugin tuple still fails to compile.

Also removed the remaining casts this exposed: hast property reads went
through `as string` even though hast property values are a union, so a
non-string `data-*` attribute would have been typed as a lie — replaced with
a runtime-narrowing helper. `toHtml()` already returns string; its cast was
redundant. Node position reads are narrowed in one helper. No cast or
ts-ignore remains in production rich-content.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 243 files / 2780 tests all passing.

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

* fix(rich-content): cached-height hydration, scroll-root + recycle latch, CDN reactivity (MUL-4922)

Three review blockers.

1. Cached reserved height still reached the first frame
The previous fix made `mounted` deterministic but left the *height* reading
sessionStorage during render: RichFenceBlock called reservedMermaidHeightPx()
inline. Server has no sessionStorage so it emitted 280px, while a browser with
a warm cache emitted the real height — a differing style="min-height:…" on the
frame React hydrates, which React reports as an attribute mismatch and does
not repair. Same bug class as the one already fixed, one layer up.

RichFenceBlock now splits into Mermaid/HTML components (so the height hook is
never conditional) and reserves the skeleton default on the first frame,
adopting the cached height in an effect. Zero-shift on a warm cache is kept;
only the read moves after hydration.

The earlier SSR suite passed a fixed reservedHeightPx straight to the lazy
shell, bypassing this path — it could not have caught this. New tests drive the
real RichFenceBlock with a real prefilled sessionStorage entry, and simulate
the server by removing sessionStorage for the renderToString call (jsdom
provides one, so without that the "server" takes the browser branch and the
mismatch is invisible).

2. Wrong observer root, and a latch that died with the row
The IntersectionObserver set only rootMargin, so it clipped against the
viewport — but Chat scrolls inside its own element (Virtuoso
customScrollParent). Expanding the viewport box says nothing about a nested
scroller, so Chat blocks only loaded once already visible and the preload was
effectively dead there. Surfaces now publish their scroll container through
RichContentScrollRootProvider and the observer uses it as `root`; page-scrolled
surfaces keep the viewport root.

Separately, the mount latch was component state, so Virtuoso recycling a row
discarded it and scrolling back re-ran Mermaid, rebuilt the iframe and dropped
viewer pan/zoom — the per-pass cost the shell exists to prevent. The latch moved
to a module-level registry keyed by a hash of the content, bounded at 500
entries with oldest-first eviction. Restore happens in a layout effect (before
paint, so no placeholder flash) and never in initial state, keeping the
server/client first frame identical. Scope was not narrowed.

3. CDN config arriving late never reprocessed content
preprocessMarkdown read configStore.getState().cdnDomain imperatively and
RichContent's memo depended only on `content`, so content rendered before the
async config landed kept legacy CDN links as plain anchors permanently.
cdnDomain is now an explicit parameter: RichContent subscribes via
useConfigStore and puts it in the memo deps, while the Tiptap editor — which
genuinely preprocesses once at load — reads the store at its own call sites. No
fallback branch.

Each fix was verified by reinstating its bug and confirming the new tests fail
(2/5, 2/14 and 1/3 respectively) rather than trusting a green run.

Also fixes a false positive in the boundary guard: the Tiptap check read raw
file text instead of using the suite's stripComments helper, so a doc comment
mentioning RichContent counted as a violation.

Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 245 files / 2793 tests all passing.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:40:00 +08:00
beast
dbb515b7b4 fix(views): close project picker dropdown after selecting a project (#5663)
The create-issue dialogs wire pickers with open={cond ? true : undefined}. Base UI latches a controlled open={true} and does not treat a later undefined as closed, so the project dropdown remains visible after selection. Normalize the picker to an always-boolean open state, matching the other field pickers.

Co-authored-by: 余剑剑 <clydeyu@lilith.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 02:07:57 +08:00
Bohan Jiang
a56f41cf03 feat(projects): switch project list view control to a dropdown menu (MUL-5030) (#5677)
Align the project list view control with the issue list: instead of a
button that flips table <-> cards on click, the trigger now opens a
dropdown menu with a 'View' header and a radio group of view modes
(Table / Cards). Using DropdownMenuRadioGroup keeps it trivial to add
future view modes as new menu items.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 16:37:30 +08:00
Bohan Jiang
d3fac023c9 feat(issues): live-resort board/list on updated_at drift (MUL-5016) (#5671)
* feat(issues): live-resort board/list on updated_at drift (MUL-5016)

An open Kanban board / list sorted by "Updated date" did not re-sort in
real time when a card's updated_at advanced — it only picked up the new
order on the next fetch (navigation, invalidation, etc.).

Two triggers now re-sort live:

- comment:created: a new comment bumps the parent issue's updated_at
  server-side (MUL-5009), but the WS handler only invalidated the
  per-issue timeline cache. It now also invalidates loaded updated_at-
  sorted issue-list/board/flat keys via invalidateUpdatedAtSortedIssue
  Lists, so the commented card re-sorts (and an off-window card can
  surface). Only updated_at-sorted keys are touched.

- same-status field edit: applyIssueChange already reconciled flat
  windows on updated_at-sort drift but the bucketed board only marked
  stale on membership/status change. It now marks an updated_at-sorted
  bucketed key stale whenever the patch advances updated_at, mirroring
  the flat-window rule. Deferred to onSettle for mutations (WS flushes
  immediately), respecting the existing timing contract.

Extracted patchChangesAnyIssueField shared helper. Tests cover the
coordinator drift/targeting and the comment:created wiring.

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

* fix(issues): cover assignee-grouped boards and property/metadata events in updated_at re-sort (MUL-5016)

Addresses Elon's review of #5671.

Must-fix 1: invalidateUpdatedAtSortedIssueLists missed assignee-grouped
boards. They fold the sort into their filter bag (issueAssigneeGroupsOptions
does `{ ...filter, ...sort }`) rather than a standalone sort key, so the old
bucketed+flat enumeration skipped them. Replace it with a single predicate
invalidateQueries over issueKeys.all(wsId) that matches any query key with an
object part carrying sort_by: "updated_at" — covering status boards, flat
tables, and assignee-grouped boards (workspace + My Issues) with one rule.

Must-fix 2: custom-property and metadata edits also bump issue.updated_at
server-side (SetIssuePropertyValue / DeleteIssuePropertyValue /
SetIssueMetadataKey / DeleteIssueMetadataKey) but flow through
issue_properties:changed / issue_metadata:changed, not applyIssueChange.
Their handlers patched cards in place and only invalidated property/My-Issues/
grouped windows, so an updated_at-sorted workspace board or flat table stayed
in the old order. Call invalidateUpdatedAtSortedIssueLists from both
onIssuePropertiesChanged and onIssueMetadataChanged. Both are committed-only
paths (WS event + mutation onSuccess); the optimistic leg still uses
patchIssueProperties, so no premature refetch.

Tests: grouped-board targeting in cache-coordinator; updated_at vs position
re-sort for onIssueMetadataChanged / onIssuePropertiesChanged (incl. the
optimistic leg not refetching) in ws-updaters.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 16:03:01 +08:00
YYClaw
4ad75977eb fix(agents): reset model and thinking level on runtime change (MUL-4963) (#5615)
Closes #5614
2026-07-20 15:06:09 +08:00
YYClaw
a0ec1da425 fix(settings): restore mounted flag on remount so auto-save can settle (#5613)
The auto-save mount effect only cleared `mountedRef` in its cleanup and never
re-set it to true on setup. Under React StrictMode the initial
setup/cleanup/setup cycle therefore leaves `mountedRef.current` pinned to false
for the component's whole life. A successful save then never reaches the
terminal branch (`succeeded && mountedRef.current`), so the status is stranded
on "saving" and the success toast never fires — the settings save indicator
spins forever even though the PATCH returned 200.

Set `mountedRef.current = true` on every mount so the flag reflects the live
component. Shared by all auto-saved settings (repositories, GitHub, etc.).

Add a StrictMode regression test asserting onSuccess still fires.
2026-07-20 15:05:06 +08:00
Naiyuan Qing
41315989bd fix(editor): prevent incorrect comment autolinks (#5665) 2026-07-20 14:57:41 +08:00
Bohan Jiang
1be1fd3be1 fix(issues): label board display-settings button as Display (MUL-5011) (#5664)
The display-settings trigger showed the current sort mode (e.g. "Manual")
as its label, which reads as an unclear button name. Show a fixed
"Display" label with the sliders icon instead, matching the existing
"Display settings" tooltip. The sort mode remains visible inside the
popover's Ordering section.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 14:49:12 +08:00
Multica Eve
1483ce0825 fix(agents): always enable AI creation (MUL-4998) (#5660)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 12:46:39 +08:00
Naiyuan Qing
fae7b046f2 feat(views): support the send shortcut in manual issue create (MUL-4931) (#5583)
* feat(views): support the send shortcut in manual issue create (MUL-4931)

Agent create has had Cmd/Ctrl+Enter all along; manual create had no submit
shortcut at all, in either the title or the description.

Reuse the configurable `send` action rather than hardcoding the chord, so a
rebound or unbound shortcut follows the user's setting and the IME guards and
Shift+Enter replay come along for free:

- Description: pass `onSubmit` to ContentEditor, which already carries the
  extension.
- Title: add an opt-in `onSubmitShortcut` to the shared TitleEditor. Plain
  Enter stays inert there — #5532 removed that trigger a day ago because it
  created from half-typed titles — and a single-line title has no newline to
  trade Enter against, so the chord path refuses a plain-Enter `send` binding.
  Hosts relying on plain-Enter submit (create-project, autopilot-dialog) keep
  `onSubmit` and are untouched.

Also fixes a real double-create: `submitting` is state, so two chord presses in
one tick both read the stale value and fired two creates. A ref flips
synchronously and single-flights both create panels.

Accessibility: the empty-title Create button moves from native `disabled` to
`aria-disabled`, so it stays focusable and keyboard/SR users can finally reach
the "Enter a title to create" tooltip. Keycaps are decorative, keeping the
accessible name "Create Issue". Empty-title chord submits now focus the title
instead of silently no-oping.

Widen the `send` setting description across all four locales — it claimed to
cover only chat, comments, replies, feedback and prompts.

Tests: real-ProseMirror coverage for the title chord (every other editor test
mocks Tiptap, so nothing verified extension ordering), plus chord/plain-Enter/
empty-title/upload-gate/single-flight/keycaps/aria-disabled cases. The
single-flight test dispatches both presses inside one act(); it fails against
the old state-based guard.

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

* test(views): cover quick-create single-flight + aria-disabled visuals (MUL-4931)

Review follow-up on #5583.

The quick-create ref guard shipped without a regression test, so the claim that
both create panels were covered was only true of manual create. That path files
a real issue, so a double-fire is a duplicate issue rather than a glitch. Add
the same-tick regression: both presses dispatch inside one act(), and it fails
against the old state-based guard (expected 1 call, got 2).

The Button base only styles native `disabled` (disabled:opacity-50 /
disabled:pointer-events-none), so the aria-disabled empty-title button stayed a
fully lit, pressable-looking primary. Add local aria-disabled opacity, cursor,
and press-animation styles, with no pointer-events-none — that would break the
tooltip hover and the click that focuses the title. Verified against compiled
Tailwind output: aria-disabled:active:translate-y-0 and the base's
active:not-aria-[haspopup]:translate-y-px have equal specificity and the
override emits later, so it wins without !important.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 12:27:12 +08:00
Naiyuan Qing
fcb4798f12 fix(issues): stop cold-load render loop on the Issues route (MUL-4985) (#5643)
Board and Swimlane crashed with "Maximum update depth exceeded" on first
paint of the Issues route. During cold load the member/agent/squad
directory queries are unresolved, so useActorName's `= []` defaults
allocated a fresh array every render and its `getActorName` memo changed
identity each render. BoardView's `groups` (and SwimLaneView's
`laneGroups`) list `getActorName` as a dep, so they churned every render
and re-fired the column-resync effect without end; react-virtuoso
escalated the loop into the reported crash. This predates MUL-4797 — it
was exposed when board/swimlane columns were virtualized with a real
<Virtuoso>.

- Share stable empty references for the loading directory snapshot so
  getActorName is referentially stable across cold-load renders (root
  cause).
- Equality-guard the shared column setter in useDragSettle so a
  content-equal rebuild returns the previous reference and cannot spin
  the resync effect (defense-in-depth).

Regression coverage: useActorName stability during cold load; the
column-setter equality guard; and Board (40 cards) + Swimlane rendered
with the REAL react-virtuoso under pending directories, which reproduce
"Maximum update depth exceeded" without the fix and pass with it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 08:52:02 +08:00
Jiayuan Zhang
002ea0d879 MUL-4797: add configurable issue table view (#5454)
* feat(issues): add configurable table view

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

* test(issues): cover table columns in page fixture

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

* fix(issues): make table column picker interactive

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

* fix(issues): repair quick create and virtualize table rows

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

* fix(issues): keep pinned table cells opaque

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

* fix(issues): anchor full-width table rows

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

* fix(issues): consolidate table controls

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

* fix(issues): harden table pagination and export

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

* feat(issues): add table quick search

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

* fix(issues): make table window filters, selection, and export authoritative

Round-2 review fixes for the issues table (MUL-4797):

- Send the agents-working filter as a server ids facet so matches on
  unfetched pages surface and total/pagination/export agree; a present-
  but-empty id list yields an empty window instead of an unfiltered one.
- Reset surface selection when the membership window changes and act on
  selection ∩ visible rows in the batch toolbar, so batch actions,
  Export selected, and the count all share one authoritative set.
- Materialize the full flat window while table grouping is active, and
  suspend hierarchy nesting / parent-based grouping until the window is
  complete so structure cannot reshuffle as pages arrive; suppress
  header facet-count badges while the table window is partial.
- Resolve actor directories and the property catalog at export time and
  fail the export instead of writing Unknown* actors or dropping
  configured property columns on cold/errored lookups.
- Append a unique id tie-break to the list/grouped ORDER BY and mirror
  it in compareIssuesForSort so offset pages are stable across
  same-timestamp ties.

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

* fix(issues): bound table structure window and align chip/transport/selection

Round-3 review fixes for the issues table (MUL-4797):

- Cap whole-window materialization at TABLE_STRUCTURE_MAX_WINDOW (1000):
  below it the remaining pages load automatically — hierarchy applies
  without scrolling to the last page — and above it grouping/hierarchy
  suspend with an explicit toolbar notice instead of triggering an
  unbounded workspace download from a persisted view option.
- Give the agents-working chip the authoritative in-window running set
  (the ids-facet window query, shared key with the filter-on state) so
  its badge can no longer say 0 while the filter would find matches on
  unfetched pages; falls back to loaded-row scoping elsewhere.
- Route ids-facet windows through a new POST /api/issues/query twin —
  hundreds of running-issue UUIDs overflow the ~8 KB GET request-line
  budget of common proxies. The body carries the same key/value pairs;
  the handler rebuilds the query string and delegates to ListIssues.
- Reset surface selection during render (key-change pattern) instead of
  a post-commit effect, so no frame ever pairs new membership with the
  old selection.

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

* fix(issues): harden table auto-pagination against errors and stale totals

Round-4 review fixes for the issues table (MUL-4797):

- Stop the structure materialization loop (and the scroll sentinel) when
  the window query is in error state — a persistently failing page left
  hasNextPage true and isFetchingNextPage false after every attempt, so
  the ungated effect refired forever. Resuming is an explicit toolbar
  Retry. The advancement decision now lives in a pure, tested
  shouldAutoLoadNextStructurePage helper.
- Make the structure ceiling a hard stop: the ceiling check reads the
  LATEST page's total (pagination already advances on it, so a stale
  small page-1 total could re-open unbounded materialization), and the
  loop additionally halts on loaded count >= ceiling regardless of any
  reported total.
- Drive the working (ids-facet) window to completion — it is inherently
  bounded by the running set — and treat it as the chip's authoritative
  scope only when complete, so >100 running issues no longer under-count
  as a single page.

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

* fix(issues): make working-window pagination capped and unknown-aware

Round-5 (final) review fixes for the issues table (MUL-4797):

- The working (ids-facet) window now advances through the same
  shouldAutoLoadNextWindowPage gates as the structure loop — it shares
  the main table's cache key while the agents-working filter is on, so
  an uncapped chip-driven loop re-opened the very ceiling the table just
  enforced. An over-ceiling window stops after page one.
- A cold-load failure of the flat window is an ERROR state, not an empty
  workspace: isEmpty only claims empty on a successful zero-result
  fetch, and the surface renders a dedicated failed-to-load state with a
  reachable Retry (the in-table Retry never mounted without data).
- The chip scope is now tri-state honest: a COMPLETE window (or an empty
  running set) yields a precise count, keepPreviousData carries the
  last-known-complete set across re-keys, and everything else — cold
  resolving, failed, over the ceiling — presents as an explicit unknown
  ('Agents working: —') instead of a number derived from whichever
  incomplete window happened to be loaded.

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

* fix(issues): single pagination owner and placeholder-honest chip scope

Round-6 review fixes for the issues table (MUL-4797):

- Exclude placeholder data from the working-window completeness gate:
  on a re-key (running set or facet change) keepPreviousData leaves the
  OLD key's rows visible, and pairing them with the new task snapshot
  published a precise-looking number for a scope nobody fetched. The
  scope now reads unknown until the new key resolves.
- Make the shared table query single-owner while the agents-working
  filter is on: the chip's background loop no longer answers the same
  render snapshot as TableView's structure loop, and every auto caller
  (structure loop, working loop, scroll sentinel, retry) now uses
  fetchNextPage({cancelRefetch: false}) so a concurrent responder
  no-ops instead of cancel/restarting a fetch whose HTTP request is not
  abortable — which had been duplicating every offset.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 19:21:27 +08:00
Bohan Jiang
28969a8493 fix(autopilots): clarify Chinese schedule day-pattern labels (MUL-4927) (#5576)
* fix(autopilots): clarify Chinese schedule day-pattern labels (MUL-4927)

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

* fix(autopilots): make weekly day-pattern label more explicit (每周特定几天) (MUL-4927)

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 17:11:20 +08:00
YYClaw
71141559c2 fix(shortcuts): ignore synthetic key events without a key
Guard shortcut parsing against synthetic key events that do not expose a string key.
2026-07-17 16:51:21 +08:00
YYClaw
465546b83b feat(autopilots): redesign the autopilot schedule editor (#5457)
Replace the free-form trigger-config form with a structured schedule
editor built on an orthogonal cron model: separate frequency, time, and
day-of-week/day-of-month dimensions map to and from cron expressions via
a dedicated grammar and mapping layer, with validation and a
human-readable describe() summary. The grammar suite drives the editor
against a combinatorially generated corpus of 51,755 distinct cron
expressions - every token form of every field, crossed - each judged
against a reference robfig/cron v3 parser.

Add a server-side /autopilot/cron-preview endpoint (plus schema and
React Query hook) so the editor shows upcoming run times, and echo
wildcard-carrying cron lists correctly instead of collapsing them.

Supporting pieces: timezone-aware formatting helper, segmented-toggle
and debounced-value utilities, a reworked time-input, and refreshed
en/ja/ko/zh-Hans locale strings.
2026-07-17 16:44:31 +08:00
Naiyuan Qing
17ee59ccc5 feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908) (#5564)
* feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908)

Mermaid's fullscreen view was a bespoke lightbox: no visible close button, no
canvas interaction, and a toolbar that scrolled away with wide diagrams. Escape
also stopped working once the iframe took focus, which is why it read as
"impossible to close".

Replace it with a viewer built on the shared Dialog, so it inherits the
backdrop, focus trap, scroll lock, focus restore and Escape handling that HTML
preview already had.

The diagram stays in an `sandbox=""` iframe — isolation is not relaxed to buy
interactivity. Instead the iframe is `pointer-events: none` and pan/zoom is
applied from the host document as a transform on its wrapper. That inversion is
also what keeps Escape working: the iframe can never take focus.

- Viewer: fit-on-open, drag pan, wheel/pinch/keyboard zoom (25%-400%) anchored
  at the pointer, Fit / 100% / Reset, and a header toolbar that cannot scroll
  away. Panning is clamped so the canvas can never be lost.
- Export: `.mmd`, `.svg` and `.png`. Requires `htmlLabels: false` — browsers do
  not rasterize Mermaid's default HTML-in-foreignObject labels through an
  `<img>`; verified in Chromium that they paint zero pixels AND taint the
  canvas, so PNG export silently produced nothing.
- Inline: toolbar lifted out of the scroll container, natural-size rendering
  (small diagrams centered, wide ones scroll at a readable size with edge
  fades), copy source, and a parser message on the error fallback.
- Theme switches no longer close the viewer or discard zoom/position; the
  previous diagram stays on screen until its replacement is ready.

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

* fix(editor): stop Mermaid drags selecting text and misfiring the viewer (MUL-4908)

Two gesture defects on the inline diagram, reported by Naiyuan.

1. Dragging a diagram started a native text selection. The iframe is a replaced
   element, so the whole diagram box got painted with the selection highlight
   and the drag ran on into the surrounding comment text. Fixed with
   `user-select: none` on the inline scroller and the viewer canvas.

   Deliberately NOT by preventDefault-ing pointerdown: verified in Chromium that
   it also drops the default focus, which silently kills the viewer's keyboard
   controls (+/-, 0, arrows).

2. Any drag ended in a `click`, so trying to look along a wide diagram opened
   the viewer on top of the user. The inline diagram had no drag handling at
   all — only `onClick`. Suppressing the selection alone would have made this
   fire more reliably, not less, so both had to land together.

   Past a 5px threshold the gesture is a drag for good: releasing no longer
   taps, and a horizontal drag pans the scroller instead. This holds even when
   the diagram has no room to scroll, so an unscrollable diagram cannot open on
   release either. A still click, a sub-threshold jitter, and the expand button
   all still open the viewer.

Touch is left alone — it already pans natively and its vertical drags belong to
the page; the browser announces its takeover with pointercancel. Mouse and pen
have no native drag-to-scroll and are panned here.

Verified in Chromium: still click opens; a 150px drag pans and does not open; a
drag on an unscrollable diagram does not open; 3px jitter still opens; and no
gesture selects text.

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

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 16:07:37 +08:00
Bohan Jiang
d1c7e1c56d fix(chat): always scroll to bottom when switching chat session (#5566)
The Chat tab (and floating chat window) both render the shared
ChatMessageList, re-keyed by activeSessionId so it fully remounts on
every session switch. The Virtuoso had no initial-position prop, so a
fresh mount rendered from the top; the only downward scroll driver was
followOutput, which reacts to post-mount data growth and is gated on
isNearBottom. Whether it landed at the bottom depended on a race
(cached sessions resolve synchronously and stuck at the top; fetched
ones sometimes caught a growth tick), producing the inconsistent
top-vs-bottom behavior.

Pin the newly-mounted list to the newest message with
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }} so switching
sessions always lands at the bottom. align: 'end' also bottom-aligns a
last message taller than the viewport.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 15:01:27 +08:00
Naiyuan Qing
ce15300493 MUL-4884: feat(issues): anchor the working chip on agents + real colour tiers (#5540)
* fix(issues): anchor the working chip on issues the filter actually shows (MUL-4884)

The header chip showed three units at once: the number counted distinct
issues, the avatar stack counted agents (with a rival "+N"), and the hover
card counted tasks — under a label with no noun at all. Each figure was
self-consistent; together they read as a miscount. c4209ec7c flipped the
number from agents to issues but left the other three surfaces on their old
units.

The chip now says one thing — "N issues in progress" — where N is the number
of rows clicking it leaves.

Data layer:

- The count is no longer re-derived from the task snapshot. The surface
  exposes `workingScopeIssues`: the render pipeline's own output with
  `workingOnly` forced on, so "chip count === row count" holds by
  construction instead of by convention. It previously counted running
  issue_ids against the PRE-filter set, so any active status/assignee/label
  filter — or a sub-issue hidden by the display toggle — made the chip
  disagree with the list it was filtering.
- Chat/autopilot tasks carry issue_id "" (not null). They used to collapse
  into one bucket and read as a phantom issue, inflating the count by exactly
  one whenever any were running. They are now bucketed out and disclosed.
- The list loads one page per status (50), so running work can exist past the
  window. The count stays list-anchored — counting rows a click cannot show
  would break the chip's whole promise — and the gap is stated in the hover
  card rather than dropped silently.

UI:

- Avatar stack is ambience, not a statistic: no "+N"; the tail fades and the
  exact roster lives in the hover card.
- Hover card groups rows by issue, names both counted units in its header
  ("3 issues in progress · 4 tasks"), and footnotes anything excluded — only
  when non-zero.
- Colour is two-step: idle activity is a brand tint, the filled state is
  reserved for "filter is ON".

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

* fix(issues): scope the working chip to the rows swimlane and gantt actually draw (MUL-4884)

Review found two modes where `workingScopeIssues` still came from a
different set than the one on screen — the exact drift this change set is
meant to remove.

Swimlane: scoped to the statusless `swimlaneIssues`, but SwimLaneView draws
its cards from `issues` (status filter applied) and only uses the statusless
set for LANE DISCOVERY. A status-filtered swimlane counted rows the canvas
never drew. Swimlane needs no branch at all — it renders the flat filtered
list like board and list do — so the special case is gone rather than
corrected.

Gantt: the canvas applies two rules of its own before drawing — a row needs a
date to be placed, and done/cancelled hide unless `ganttShowCompleted` is on
— so a done issue with a running agent counted toward the chip while the
canvas refused to draw it.

Those rules now live in the surface (`ganttCanvasRows`) instead of privately
inside GanttView, and both `filteredGanttIssues` and the working scope go
through them. Mirroring the rules in a second place would have reproduced the
original bug with extra steps; hoisting them means the chip narrows the same
set the canvas draws, by construction. GanttView goes back to being a
renderer: it orders rows, it does not decide which ones exist.

Regression tests cover both, and each was confirmed to fail against the code
it guards: swimlane asserts the statusless lane source still holds the wider
set, so a revert fails loudly rather than passing by luck; gantt covers the
hidden done row, the undated row, and the show-completed toggle widening both
the canvas and the scope together.

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

* feat(issues): anchor the working chip on agents, and give it real colour tiers (MUL-4884)

Follows the approved final mock. Two changes, one root cause each.

SEMANTICS: the chip now counts agents, not issues.

The original bug was "+1" (agents) sitting next to "3" (issues) — two units
in one control. Anchoring on issues fixed which number was authoritative but
left the units mismatched, so the avatar stack had to lose its +N and the
label had to grow a noun to suppress the contradiction. Counting agents makes
the number and the heads beside it the same list: the mismatch is gone rather
than managed. It also settles the subject — only agents emit a runtime
signal, so "N agents working" cannot be misread as "N issues someone is
working on", which "N issues in progress" invited.

Accepted trade-off: the number no longer predicts the row count of the click.
One agent on two issues reads "1 agent working" and opens two rows. The chip
answers WHO, the list answers WHERE — different questions, so they don't
compete, and the hover card's issue grouping shows the mapping. The
workingScopeIssues pipeline stays: it still decides WHICH agents count.

The hover card drops both "not counted" footnotes. They explained an absence
the user never perceived — chat/autopilot runs leave no row, no head, no
indicator on this page — so they invented a discrepancy instead of resolving
one. The empty-issue_id guard stays: that is data correctness (those agents
aren't on any issue), independent of whether we narrate it.

Avatar overflow returns to the component's standard +N badge; the fade
variant is deleted. With an agent-anchored number, "3 shown + 1 = 4"
corroborates the text instead of competing with it.

COLOUR: three tiers, each a self-contained Button variant.

Layering brand classes over `outline` could never work in dark mode:
`outline` ships dark:bg-input/30 / dark:border-input / dark:hover:bg-input/50,
tailwind-merge keeps them (different modifier group), and they win the
cascade — `dark` compiles to `&:is(.dark *)`, so they outrank a bare
.bg-brand on specificity, not just order. Both brand tiers were dead in dark;
--brand-foreground equals --foreground there, so filter-on and filter-off
rendered pixel-identical.

New `brand` / `brandSubtle` variants own every state instead: hover, pressed,
and aria-expanded pinned to the hover value so opening the popover doesn't
read as a colour change. `brand` needs no dark: at all — the token flips per
theme. `brandSubtle` runs one notch hotter in dark, where the same alpha
reads weaker.

Verified against the compiled stylesheet rather than by asserting class
strings — a string assertion is what let the dark bug through. A cascade
check resolves each tier x {default,hover,pressed,popover-open} x
{light,dark} by specificity and source order; all 16 brand cells land on
their intended notch, identical in both themes for `brand`.

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

* fix(issues): stop the empty-state muted text from overriding the brand tier (MUL-4884)

Review caught the colour rule breaking itself, and the test that was supposed
to guard it being unable to.

`filter ON + 0 agents` is a real state — the filter stays on after the last
agent finishes. There the variant is `brand`, and the chip appended
`text-muted-foreground` for the empty state regardless of the filter.
tailwind-merge keeps the last class in a group and `className` is merged
after the variant, so the muted grey WON: grey text on a brand-blue fill. The
irony is exact — this change set exists because colour classes in `className`
lose to a variant's `dark:` chain, and here one beat the variant instead.
Either way the lesson is the same: a tier's colours only ever come from its
variant. `chipAppearance` now makes that a rule with one gated exception
instead of an inline ternary, and says why.

The variant assertion could not catch this: the variant IS `brand` while the
text is overridden. That is the same shape of false confidence as the class
-string assertion it replaced, so this commit brings the cascade check into
the repo instead of leaving it as something a commit message claimed.

`apps/web/app/brand-variant-cascade.test.ts` compiles the real globals.css —
web's and desktop's, since each defines its own `dark` variant — and resolves
what a browser would paint for the merged class strings: filter declarations
matching the element's classes and state, then take the winner by
specificity, then source order. It asserts both tiers across default / hover
/ pressed / popover-open x light / dark, and pins the two ways the colour has
actually been lost as executable failures: layering brand over `outline`
(dark:bg-input/30 wins by specificity) and appending a colour class (wins by
merge order).

Both new guards were confirmed to fail against the code they guard: reverting
the gate fails the chipAppearance test; giving `brand` a dark: rule fails the
cascade test in both bundles.

Also refreshes the comments still describing the retired issue-anchored
invariant ("chip count === row count"). The scope pipeline they describe is
unchanged and still load-bearing — it decides WHICH agents count — but the
chip's number is agents, so it is no longer this list's length.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:56:23 +08:00
Jiayuan Zhang
82906bd9e9 feat(settings): move create-issue field config into a Settings Issue tab (#5556)
The quick-create field toggles used to live inside the quick create
popover; per feedback on MUL-4873 they belong in the Settings page.
Adds a My Account 'Issue' tab with one group per create mode backed by
a new per-workspace issue-create-settings store, extends field
visibility to the manual dialog (status/priority/assignee/labels/
project/due date/start date with value-reveal and overflow re-entry),
and routes both dialogs' 'Customize fields' items to the tab.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:36:13 +08:00
Jiayuan Zhang
ed9adc2bbe feat: improve create issue field controls (#5532)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 11:11:52 +08:00
Jiayuan Zhang
d833ef520d fix(editor): preserve ordered list numbering on rich paste (#5538)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 11:11:17 +08:00
Naiyuan Qing
a18cc65b35 fix(inbox): render archived rows as read (MUL-4893) (#5549)
Archiving deliberately leaves `read` untouched so unarchiving can restore
the real unread state (MUL-3736). InboxListItem did not account for the
view, so archived rows kept an unread dot and bold title that no action in
the archived view can clear.

Gate the unread affordance on the view at the display layer, matching the
precedent set for chat in MUL-4360. The `read` field and all archive write
paths are unchanged, so unarchiving still restores real unread state.

Route all four read-driven styles (dot, title weight, detail label,
timestamp) through one `showUnread` flag so an archived row renders
consistently as read rather than half-dimmed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 09:53:38 +08:00
Naiyuan Qing
c52ebeaa0f MUL-4864 fix(chat): use a single New Chat draft, keyed by session not agent (#5519)
* fix(chat): use a single New Chat draft, keyed by session not agent (MUL-4864)

An uncreated chat kept one draft per agent (`__new__:<agentId>`), so
switching the agent picker mid-compose swapped the draft out from under
the user and left invisible per-agent drafts behind. That slot shape was
never a product decision: it fell out of 34e452776, which keyed the
editor by agent to remount it and refresh the Tiptap placeholder. The
placeholder now syncs live, so the motive is gone — and mobile already
implements the target contract.

An uncreated chat now has ONE draft slot per workspace. `selectedAgentId`
is the send target, not draft ownership. Created sessions keep their own
per-session slots, so conversations stay isolated.

Three connected changes this needs to actually hold:

- Editor identity no longer tracks the agent. Keying it by agent kept the
  draft slot correct but still remounted on switch, dropping whatever the
  100ms draft debounce had not yet persisted — the last thing typed.
- The post-send "scrub the composer?" rule reads `activeSessionId` alone.
  Moving the picker is no longer navigation, so counting it as such left
  a completed send's text in the composer, primed to be sent a second
  time to the agent just picked. Both send chains (the chat tab's
  controller and the floating ChatWindow) now share one exported rule
  rather than two copies of the predicate.
- Legacy `__new__:<agentId>` slots are folded on load. They carry no
  timestamp, so when several exist none can be shown to be newest: adopt
  the persisted `selectedAgentId`'s slot (the draft the workspace would
  have opened with) and drop the rest — those extras are the invisible
  multi-draft state this removes. Text and attachments migrate together
  so a draft can't end up with another agent's files.

Tests cover the acceptance scenarios: agent switch preserves text,
attachments and the live editor instance; sessions stay isolated; the
first send clears the draft and routes to the agent selected at send
time; a failed create keeps the input; and the migration adopts, prunes,
persists, and is idempotent.

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

* fix(chat): file unflushed keystrokes under the draft they were typed in (MUL-4864)

Review blocker: one editor instance serves every chat draft and its
onUpdate is debounced 100ms. Typing in session A and switching to B
inside that window fires the timer with B's draftKey in scope — because
onUpdate always resolves to the latest render's closure — writing A's
document into B's draft. Worse, ContentEditor's dirty guard suppresses
B's incoming sync while those unflushed bytes are live, so B's own draft
never loads and A's context could be sent to B's agent.

This predates the per-agent draft change: on main `editorKey` is
`selectedAgentId`, so any two sessions of the SAME agent already shared
one editor instance and already cross-wrote. Verified by running the new
regression against main's editor identity with the flush removed — it
fails there too. Unifying the New Chat draft widened the same hazard to
cross-agent switches, so it is fixed here rather than left latent.

Fix at the root: a debounced write must land on the key it was typed
under, not wherever the composer now points.

- ContentEditor gains `flushPendingUpdate()`: cancels the armed debounce
  and hands its markdown back rather than firing it, and advances the
  emit watermark so the editor reads clean and the dirty guard stops
  blocking the incoming sync. Distinct from flushPendingOnUnmount — the
  instance is alive, so it reads the live document.
- ChatInput flushes on draftKey change and commits the result to the
  PREVIOUS key. useLayoutEffect, not useEffect: passive effects run
  child-first, so a passive flush would land after ContentEditor's sync
  had already skipped on a dirty editor; a layout effect is part of the
  commit, so no pending timer can fire ahead of it.
- onUpdate and the flush now share one `commitDraft`, so text and
  attachment pruning cannot diverge between the two paths.

Tests exercise the REAL debounce and REAL dirty guard, not an instant
mock onUpdate:

- chat-input-draft-isolation.test.tsx (new): mounts the real
  ContentEditor with only Tiptap's primitive mocked. Pins A/B isolation
  across a mid-debounce switch, that B's draft actually loads, that a
  lazy session create keeps its bytes in the New Chat slot, and that an
  agent switch still preserves the New Chat draft. Three of four fail
  without the flush.
- content-editor.test.tsx: flushPendingUpdate hands back the pending
  markdown, kills the timer, unblocks the next sync, and leaves the
  guard intact when NOT flushed.

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

* fix(chat): keep an in-flight upload bound to the draft it started in (MUL-4864)

Second review blocker. An upload PINS the editor to its source document:
Guard 0 refuses to setContent over an `uploading` node, because wiping it
strands the upload's finalize and the file silently vanishes. So while an
upload runs, the instance still holds A's document even though the user
has navigated to B — and the upload's own completion dispatch fires
onUpdate on that instance, which resolves to the latest render's closure.
Result: A's body and its attachment URL were written into B's draft, B's
own draft was destroyed, and A lost the upload it was waiting for.

Nothing stopped this: useUploadGate gates submit, not session/agent
navigation. Reproduced as a test before fixing — B's "B's own words" was
replaced by A's body + A's CDN URL.

Root cause is the same shape as the debounce bug, one level up: the
editor's writes were keyed off what is SELECTED, when they belong to what
is LOADED. Those are the same key except while an upload pins the
document.

- chat-input models that explicitly with `editorDraftKeyRef` — the draft
  whose document the instance holds. Every editor-driven write (onUpdate,
  the upload's attachment binding) uses it, not `draftKey`.
- The draft switch defers while `hasActiveUploads()`, leaving both the
  document and its writes on the source key, then completes when the gate
  clears. Blocking navigation instead would make the user wait on a
  network round-trip to change tabs.
- The deferred case must force the adopt: ContentEditor's sync effect
  CONSUMED the defaultValue change while Guard 0 was up
  (lastDefaultValueRef advances before the guard), so it never re-runs and
  B's draft would never load. New `adoptContent()` ref method lands it;
  its body is the sync effect's own apply path, extracted so both agree.
- handleSend refuses while loaded !== selected. The upload gate covers
  most of that window but not the sliver between the upload's final
  dispatch and the adopt re-render — React flushes that on a scheduler
  task, so Mod+Enter can land first.

Tests (real ContentEditor, real Guard 0, real debounce, real upload
lifecycle incl. the transaction that publishes the queue flip):
completing upload's markdown stays in the source draft and never reaches
the target; an attachment dropped while pinned binds to the source; the
target draft loads once the guard clears; send is refused mid-divergence.
Each verified to fail without its fix — the send guard initially passed
vacuously (the disabled SubmitButton swallowed the click) and now drives
the real Mod+Enter path.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:56:55 +08:00
Naiyuan Qing
6b2097ccbb feat(inbox): archived notifications sub-view (MUL-3736) (#5518)
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.

Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
  existing GET /api/inbox so installed clients keep their contract and the
  unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
  is issue-level, so a new notification on an archived issue leaves old archived
  rows beside a fresh active one — without the guard the issue renders in BOTH
  lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
  restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
  oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
  inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
  workspace/archived/created_at.

Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
  prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
  detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
  offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
  own archived view remains follow-up.

Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.

Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:58:42 +08:00
Bohan Jiang
10391939c0 test(chat): de-flake use-chat-draft-restore reconciliation waitFor (MUL-4856) (#5516)
The reconciliation test drives real react-query fetches and mutations, so
each waitFor gates on a multi-hop async chain (fetch -> effect -> mutate ->
settle -> refetch). The 're-consumes on the next fetch after the consume
failed' case chains the most hops and timed out under the default 1s waitFor
budget when the full Vitest suite saturated the CI runner (whole run 405s,
import 364s), failing with 'expected 2 times, but got 1 times' on the main
merge of an unrelated Go-only PR.

Widen this file's async-utility and per-test timeouts so whole-suite
contention can't starve the chain. Test-only; defaults stay in force
elsewhere (Vitest isolates config per file).

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:07:52 +08:00
Multica Eve
2d9cf045d0 MUL-4861: align runtime CLI update permissions (#5514)
* fix(runtimes): align CLI update permissions

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

* fix(runtimes): address update review nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:54:01 +08:00
Bohan Jiang
4fcb27a729 fix(issues): attach labels in the create transaction (MUL-4832) (#5510)
* fix(issues): attach labels in the create transaction (MUL-4832)

Labels chosen at issue creation were attached in a second, non-atomic
round-trip after the issue was already committed (web modal looped
attachLabelToIssue per label; the create endpoint took no labels). A
partial failure of that follow-up left the issue committed but
mis-categorized, surfaced only as a toast.

Carry label_ids through the create request instead: the service
validates them (workspace + resource_type='issue') and attaches them
inside the same transaction as the issue insert, so the issue and its
labels commit together or not at all. An unknown or wrong-scope label
id now fails the whole create with 400 rather than being silently
dropped. Duplicate ids are idempotent (dedupe + ON CONFLICT DO NOTHING).

- server: IssueCreateParams.LabelIDs + ErrIssueLabelNotFound; validate
  and attach in IssueService.Create; handler parses label_ids and maps
  the error to 400.
- web: create-issue modal forwards label_ids and drops the post-create
  attach loop and its dead toast key.
- tests: handler coverage for atomic attach, stale-id 400 (no issue
  left behind), duplicate-id idempotency, wrong-scope rejection; web
  test asserts label_ids is forwarded.

Scope is deliberately labels-only. The contributor PR #5475 also
auto-parsed an acceptance_criteria field from description text; that
introduces a new user-facing data contract with no defined edit/display
rules and is left out for a separate product decision.

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

* fix(issues): echo created labels + guard old-backend compat (MUL-4832)

Addresses review of #5510.

1. Create response + issue:created event now carry the labels attached in
   the create transaction. IssueService.Create returns the authoritative
   label snapshot; the handler sets it on both the HTTP response and the
   issue:created broadcast payload. Without this, online members other than
   the creator saw the new issue unlabeled indefinitely (staleTime: Infinity,
   no invalidation) because this PR removed the old post-create
   issue_labels:changed broadcast.

2. New web + old backend compatibility. During the rolling deploy window the
   web app can run ahead of the backend (web auto-deploys on merge, backend
   deploys manually). An older backend ignores label_ids and returns an issue
   with no labels field. The create modal now falls back to the legacy
   per-label attach only when the response omits labels, so labels aren't
   silently dropped; when labels is present the atomic path already ran and
   no fallback fires. The backend always returns an explicit labels array
   (empty when none) as the detection signal.

Tests: handler issue:created-carries-labels + response-carries-labels +
response-always-includes-labels; web modal no-fallback (new backend) and
fallback (old backend); ws-updaters keeps the label snapshot in list cache.

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

* fix(issues): validate create response through a schema (MUL-4832)

Addresses review of #5510 (must-fix 3).

The create modal keys its label-attach compatibility fallback off
`issue.labels` being absent (older backend) vs a validated Label[]
(current backend). But api.createIssue cast the raw JSON straight to
Issue, so a malformed labels value (null, an object, a garbage array)
would be !== undefined and wrongly suppress the fallback, caching a bad
shape — violating the repo's API Compatibility rule.

Parse the create response through CreateIssueResponseSchema:
- labels absent  -> undefined (older-backend signal; fallback runs)
- labels valid   -> Label[] (fully validated elements, not z.unknown())
- labels malformed -> undefined via .catch (safe: never masquerades as
  handled; worst case a redundant re-attach, never a silent drop)
A whole-body parse failure degrades to EMPTY_ISSUE (never throws into
React), matching the existing parseWithFallback contract.

Tests: schema.test.ts covers absent / valid / null / wrong-element-shape
labels and the empty-issue degrade.

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

* fix(issues): reject a malformed create response instead of faking success (MUL-4832)

Follow-up to review of #5510. The prior fix degraded a schema-failed
create response to EMPTY_ISSUE, which React Query treats as a successful
mutation: the empty issue is written into the list cache, a blank
"created" toast shows, the "View issue" link points at an empty id, and
with labels the fallback attach runs against an empty issue id.

A create that returns an unusable body is a failed mutation, not a
safe-empty read. Fall back to null and reject: mutateAsync is already
inside the create modal's try/catch, so a controlled rejection preserves
the draft and shows the failure toast, and onSettled still refreshes the
list so a genuinely-created issue can still surface.

- CreateIssueResponseSchema tightens id to non-empty; an id-less body
  routes to the same reject path.
- createIssue throws on parse failure (empty-message Error so the modal
  renders its localized "failed to create" toast; parseWithFallback
  already logged the schema issues + raw body).
- Dropped the EMPTY_ISSUE fallback constant.
- Tests: whole-body malformed and empty-id now assert createIssue
  rejects; only-labels-malformed still returns the real issue with
  labels undefined.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:31:06 +08:00
Naiyuan Qing
f9f41e991a fix(ui): drop native title that stacked on custom hover surfaces (MUL-4836) (#5502)
ActorAvatarBase hardcoded `title={name}` on the avatar div, so every one of
the 155 avatar renders emitted a browser tooltip. On the 34 call sites that
also pass `enableHoverCard`, the native title sat on the inner div while the
PreviewCard trigger sat on an ancestor span — different elements, so neither
suppressed the other and both surfaces showed at once.

Same class of bug in two more places: AgentStatusDot added a third `title`
inside the same trigger on the 16 sites that also pass `showStatusDot`, and
RepoUrlText rendered a `title` carrying the exact string its Tooltip already
showed.

Phase 1 scope only — remove the three duplicate titles. PreviewCard trigger
semantics, `profileLink`, ancestor detection, the 159 call sites, and the
remaining native-title cleanup are deliberately untouched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 11:13:31 +08:00
CAVIN
310634064f fix(transcript): explain unavailable Antigravity live events (#5182)
* docs: design Antigravity live transcript empty state

* docs: plan Antigravity live transcript empty state

* fix(transcript): explain unavailable Antigravity live events

* chore: remove agent planning artifacts

---------

Co-authored-by: CAVIN <zzz163519@users.noreply.github.com>
2026-07-16 10:28:23 +08:00
Naiyuan Qing
3b08456264 feat(views): block draft-fixing actions while attachments upload (#5465)
Submitting a composer mid-upload silently lost the file: the editor holds a
`blob:` placeholder that gets stripped during serialization, and the
attachment id does not exist yet, so the send succeeded without the file.
Chat and Quick Create gated this; manual issue create, Feedback's button,
comments, replies and comment edit did not.

Gate every action that FIXES a draft — Send/Create/Save, Cmd/Ctrl+Enter,
Enter on the title, and the manual/agent mode switches — behind one source
of truth: the editor document, which IS the upload queue. ContentEditor
publishes queue transitions via `onUploadingChange` (a transaction listener,
not `onUpdate` — that path is debounced and skips no-change emissions, and a
failed upload leaves byte-identical markdown, so the un-gate would never
fire). `useUploadGate` pairs that render state with an `isBlocked()` re-read
at submit time, since the shortcut paths never consult the button.

The publisher emits its current answer on subscribe rather than only on
flips: hosts outlive editor instances (comment edit remounts on cancel,
chat swaps by key on agent switch), and an editor torn down mid-upload would
otherwise leave submit wedged shut with no pending node left to reopen it.

Also fixes the failure toast that never fired: `uploadWithToast` only
reported errors if a caller passed `onError`, and none did, so failed uploads
removed their placeholder and vanished unexplained. `useEditorUpload` now
supplies it once for every composer.

Per MUL-4808: drops Quick Create's upload-time lock on the attach button
(files can queue), and leaves description autosave ungated by design.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 09:56:24 +08:00
Naiyuan Qing
e8ada0e89c fix(views): stop avatar hover cards opening on brushed-past avatars (MUL-4827) (#5497)
ActorAvatarHoverCardShell deferred mounting the Base UI PreviewCard root
until the first pointerenter, then emulated the missing first-dwell open
with a manual 600ms timer. Base UI drives hover through native
mouseenter/mouseleave listeners on the trigger and installs its close path
inside the mouseleave handler, so swapping the node mid-gesture loses both
the event that cancels a pending open and the one that closes the popup.

Flicking the pointer across a dense list armed one timer per avatar and
cancelled none: in real Chromium a single sweep left 5 profile cards open
with the pointer nowhere near them, and no hover-close path remained. The
same cold span also opened on touch taps (Base UI sets mouseOnly) and
opened instantly on focus, with no focus-visible gate and no delay.

Drop the cold/warm swap, the manual timer, the manual focus-open and the
focus restore, and let Base UI drive hover and focus natively. Appearance,
tabIndex and the standalone-ancestor probe are unchanged.

Mounting the root eagerly costs ~0.13ms of JS per avatar in real Chromium
(+19.5ms at 150 avatars, the widest realistic board) and adds zero DOM
while closed -- the popup subtree and its queries stay unmounted until
open. Virtualization already caps mounted rows at 30 (10 per board
column), and the popup-deferral work that motivated the hack is untouched.

Verified: tsc --noEmit, eslint, and @multica/views vitest (212 files /
2102 tests) pass. A real-Chromium harness passes 7/7 hover/touch/keyboard
scenarios on this fix and reproduces 3 failures on the pre-fix code.
Still owed: real-device Electron trace on the widest/densest board.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:29:27 +08:00
Jiayuan Zhang
0ec7c22901 fix(issues): render create picker trigger content (#5491) 2026-07-16 08:42:00 +08:00
Jiayuan Zhang
ea8511340e MUL-4820: support custom property icons (#5468)
* feat(properties): add custom icons

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

* fix(migrations): use unique property icon prefix

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

* fix(properties): replace emoji icons with Lucide picker

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 22:37:57 +08:00
Naiyuan Qing
032c7038ee fix(editor): drop underline mark so pasted rich text stops emitting ++ (#5471)
StarterKit registers Tiptap's Underline extension by default. It maps both
`<u>` and `text-decoration: underline` to an underline mark and serializes
that mark as `++text++` — a syntax neither CommonMark nor GFM defines. The
display layer (ReadonlyContent: react-markdown + remark-gfm) has no rule for
it, so a saved comment rendered the delimiters literally.

Disable the extension in the shared StarterKit config rather than filtering
`u` out of the rich-HTML paste selector: the mark is also reachable via the
extension's own Mod-u shortcut, so a paste-only fix would leave Cmd+U able to
produce the same unrenderable Markdown.

Removing the extension also removes its `++text++` markdown tokenizer, so
existing text containing `++` now round-trips as literal text instead of
being silently reinterpreted as an underline mark.

This is shared editor config: it applies to every ContentEditor surface
(issue comments, issue descriptions, chat, agent prompts), not just the
comment box. No toolbar control, command, or stylesheet referenced the mark.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 20:06:55 +08:00
Bohan Jiang
fa17e5c168 fix(views): stop server version from stretching the help menu (#5472)
Cap the Help menu width (max-w-56) and let the server version label
wrap (break-words) so a long version string no longer widens the whole
popup. The menu still keeps its min-w-40 floor when no version is shown.

MUL-4828

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 18:41:16 +08:00
Jiayuan Zhang
fff23c1fc3 MUL-4818: keep Inbox and sidebar unread counts static (#5461)
* fix(sidebar): keep unread counters static

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

* fix(inbox): keep title unread count static

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 18:09:52 +08:00
Jiayuan Zhang
19e52e007c MUL-4798: make Inbox notification preference updates atomic (#5451)
* fix(notifications): make preference updates atomic

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

* fix(notifications): serialize preference mutations

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 17:52:10 +08:00
Bohan Jiang
449d9887b5 fix(help): wrap server-version row in DropdownMenuGroup to stop Help menu crash (#5458)
Base UI's Menu.GroupLabel (rendered by DropdownMenuLabel) calls
useMenuGroupRootContext(), which throws when it has no Menu.Group ancestor.
The Help launcher rendered the server-version DropdownMenuLabel directly under
the popup, so opening the Help menu on any deployment that returns a non-empty
server_version threw during render. No error boundary sits above the app
sidebar, so the throw unmounted the whole React tree — a black screen with no
error, exactly as reported (MUL-4819).

Wrap the row in DropdownMenuGroup, matching every other DropdownMenuLabel usage
in the codebase. Also harden help-launcher.test.tsx: the old mock flattened the
dropdown to plain divs and silently dropped the Group/GroupLabel contract, which
is why the bug shipped. The mock now mirrors Base UI's throw, so a bare label
fails the test.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 17:46:49 +08:00
Naiyuan Qing
ea03912baf perf(desktop,issues): single-router tab sessions (MUL-4741 Phase 2) + trace-driven surface mount/render overhaul (MUL-4474/4750 reland) (#5403)
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)

This reverts commit c10bfa8f56.

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

* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)

The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:

1. The scroll element reaches Virtuoso via a callback ref that lands in
   state, so the first render after a remount has customScrollParent === null
   and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
   ResizeObserver measures the viewport.

Fix both, four surfaces (list / board / swimlane / inbox):

- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
  scroll parent is still null, reusing each caller's own itemContent /
  computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
  those rows instead of collapsing to empty.

SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.

Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.

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

* perf(issues): defer per-card popup mounting, share one context menu per surface

Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.

- DeferredPopup: pickers render a pixel-identical static trigger and mount
  the real popover on first pointerenter/keydown (Base UI opens on click,
  so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
  AssigneePicker's members/agents/squads/frequency subscriptions now only
  start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
  at the cursor via a virtual anchor; items delegate (issue, position) up.
  Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
  first-dwell timer matching Base UI's OPEN_DELAY

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(issues): stabilize board column scrollbar across mounts

Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.

- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
  the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
  not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
  defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
  (same itemContent), making their scroll height browser-measured truth in
  every scenario -- the per-column split Linear ships
  (data-virtual-cluster=false for small clusters)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(editor): reduce issue detail mount cost

Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.

* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)

The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:

- renderer app code may not import useNavigate/Navigate from
  react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
  cross-validated: the rule fires on exactly those and nothing else)
  carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
  migration removes them one by one, and this rule holding with zero
  disables is the machine check that the migration is complete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(views): open deferred pickers on click, align triggerRender types

Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.

Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)

Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:

- TabSession: tabs are pure serializable state (url, resourceKey, virtual
  history stack, scroll memento). Persist v4 does the one-time legacy
  view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
  reconciles THE app router to the active session URL with navigation
  tokens; a location change without a token is a protocol error handled by
  bounded recovery (invariant 1). The router history is never used — every
  reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
  reload() = generation bump + active-scope query invalidation (never
  router.revalidate, never a global cache invalidation). Warm switches
  restore scroll pre-paint; cold restores pre-size containers from the
  memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
  /slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
  deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
  page recovery, workspace-layout login bounce, overlay parking, shell
  back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
  navigation boundary rule now holds with zero exemptions outside
  src/platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(issues): register board columns and list scroller for scroll mementos

Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)

Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.

- ScrollRestorationProvider (views/platform): views pull their saved offset
  while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
  so the first render already materializes the rows around it — this
  replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
  against Virtuoso's own height model (restore landed the viewport in
  phantom space, Virtuoso rendered nothing, and the spacer's removal
  condition could never be met → blank issue detail). Plain containers
  assign the offset at ref-attach, pre-paint. Web has no provider and
  behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
  capture now also fires before in-tab navigation, so back/forward restores
  each route's own offsets and same-named containers on different routes
  no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
  scrolled back to 0 clears its stale offset instead of resurrecting the
  old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
  the seed spacer and defaultItemHeight), which keeps the shared scroller's
  height truthful from the first frame so the restored offset sticks.

Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(editor): make unlabelled code fences plaintext instead of auto-detected

Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(issues): lazy-mount column-header popups, right-size swimlane seed

Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.

- DeferredTooltip (views/common): renders only the trigger until first
  hover, then mounts a controlled Tooltip anchored to the SAME element
  (no trigger swap, so mid-gesture events never land on a detached node);
  ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
  defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
  option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
  ~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
  redirect, explicit open-in-new-tab) are ONE store write instead of
  openTab + setActiveTab back-to-back — one full subscriber pass per user
  action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
  showed the surface mounting twice inside one task, and the my-issues
  relation toggle is one confirmed key-flip source; the log ties the next
  trace's mounts to exact key transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(issues): stop double full-tree render passes on surface interactions

Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).

- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
  already useMemo/useCallback-stable, so a controller flag flip now
  re-renders the header, not the whole board. The one unstable prop —
  BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
  Set, buying a guaranteed extra full pass per surface mount; functional
  bail keeps the reference when nothing was selected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react

Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(issues): wire swimlane's outer scroller into tab scroll restoration

Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.

Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:30:51 +08:00
YOMXXX
c599f47ba4 fix(redact): cover GitHub fine-grained PATs and Google API keys (#4678)
The primary server redactor now covers github_pat_ and standard AIza keys, but the client transcript safety net does not. Add matching client patterns and regression coverage so previously stored or otherwise unredacted transcript values are masked at display time.

Also make the fixed-length Google key rule redact a key ending in '-' while preserving its trailing delimiter. Keep the 39-character format exact to avoid broadening false positives.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 16:06:59 +08:00
chouti
b31132edf3 feat(agents): add access-scope column, filter, and bulk edit to agents list (#5393)
Adds a three-state access column/filter and a bulk "Set access scope" action to the agents list, plus a fix for a state-update loop in the bulk access dialog (parent/child callback + effect cleanup were re-triggering each other).

Co-authored-by: chouti <chouti@users.noreply.github.com>
2026-07-15 15:53:24 +08:00
Jiayuan Zhang
50e28d539c feat(settings): open issue links in new tab by default, configurable in preferences (#5445)
Issue mention chips in descriptions (tiptap), comments (readonly
markdown), and chat now open the linked issue in a new tab on plain
click — a browser tab on web, a foreground app tab on desktop. A new
Settings → Preferences switch (default on) restores in-place navigation
when turned off; the preference persists via a zustand store.

AppLink now honors target="_blank": desktop delegates to the
navigation adapter's openInNewTab with activate, web leaves the native
anchor behavior intact. This also fixes dead cmd/ctrl-click on web for
comment and editor mentions, which previously preventDefault'd without
an adapter fallback.

MUL-4793

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 14:33:00 +08:00
Multica Eve
bf64bb9214 MUL-4781: keep machine CLI status visible
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 13:35:44 +08:00
Bohan Jiang
56f453fed6 feat(agents): add loading skeleton for Recent work list (#5438)
The Recent work section on the agent Activity tab reads a lazily-loaded
per-agent task list. On first paint the query is still pending, so the
section rendered its 'nothing finished yet' empty state — a wrong answer
that flashes before real data arrives.

Render a skeleton (bordered, divided placeholder rows matching the real
TaskList shell) while the first fetch is in flight, keyed off the query's
isLoading. Once the cache is hydrated the tab opens straight into data
with no skeleton flash.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 13:15:31 +08:00
Jiayuan Zhang
07e2d378bd feat(settings): add random color option to label and property color pickers (MUL-4786) (#5437)
* feat(settings): add random color option to label and property color pickers (MUL-4786)

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

* feat(settings): unified color picker popover with presets and random for labels and property options (MUL-4786)

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 13:03:56 +08:00
Multica Eve
2c482ab366 MUL-4781: move daemon update to machine page (#5434)
* fix(runtimes): move daemon update to machine

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

* chore(runtimes): remove obsolete update hook

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 12:41:26 +08:00
Sanjay Ramadugu
06d79c1750 feat(agent): add Grok Build CLI as an ACP runtime (#5285)
Add xAI Grok Build (`grok`) as a first-class Multica runtime over ACP
(`grok --no-auto-update agent --always-approve stdio`), reusing
hermesClient like traecli and kimi. Includes daemon discovery,
protocol_family migration 174, model discovery, MCP passthrough,
thinking effort, frontend branding, and product docs.

Follows xAI's documented headless ACP flow: after `initialize`, read the
advertised `authMethods` and send `authenticate` (preferring xai.api_key
when XAI_API_KEY is set, else the cached login token) before any session
operation — a real, logged-in CLI rejects session/new and session/load
without it. Model discovery performs the same handshake so it returns the
live catalog instead of the static fallback.

`--no-auto-update` is passed as a global flag (and kept daemon-owned in
the blocked custom-arg set) so a background update check can't stall an
unattended ACP task. Thinking effort uses the current `--effort` flag,
and the minimum grok version is 0.2.89 (ACP + authenticate + session/load
+ session/set_model + MCP + --effort).

Closes #2895
2026-07-15 12:30:36 +08:00
Jiayuan Zhang
101f21d55d fix(ui): render select labels instead of values (#5435) 2026-07-15 12:21:13 +08:00