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>
The windows-execenv job's TestPrepareIsolated_WindowsKillsDescendantBeforeRetry
flakes on the Windows runner: its helper subprocess ends in a bare select{},
which the Go runtime can reap with 'all goroutines are asleep - deadlock!'
(exit status 2) once every goroutine is parked with no wakeup source. That
races the parent's Job Object kill, so PrepareIsolated returns 'helper failed'
instead of the context.Canceled the test asserts.
Block on a timer-backed sleep loop instead: a pending timer is a wakeable
source, so the runtime never declares a deadlock, and the process still dies
the instant the Job Object tears the tree down.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* feat(issues): bump issue updated_at when a comment is added (MUL-5009)
A new comment now counts as activity on its issue and advances
updated_at, so the "Updated date" Kanban/list sort surfaces
recently-discussed cards — not only cards whose status changed.
Applies to all three comment-creation paths (user/agent HTTP,
agent task delivery, and the child-done system comment) via a
best-effort TouchIssue query. The bump never fails an already-
persisted comment; it self-heals on the next activity if it errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): make comment updated_at bump atomic (MUL-5009 review)
Address Elon's review. Move the updated_at bump into CreateComment as a
leading data-modifying CTE so the comment insert and the timestamp bump
commit or roll back together — closing the non-atomic window where a
comment could persist while updated_at stayed stale. That window also
skewed the daemon GC TTL, which reads issue.updated_at to reclaim
done/cancelled workdirs.
Centralizing the bump in the query drops the three per-caller TouchIssue
calls and guarantees any future comment entrypoint inherits it.
Also refresh the now-stale gc.go / gc_test.go comments that asserted
'CreateComment does not bump issue.updated_at'.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): make comment/issue workspace match a query-level guarantee (MUL-5009 nit2)
The touch CTE now RETURNING id, workspace_id and the INSERT SELECTs from
it, so the comment insert depends on the issue actually existing in the
passed workspace. A mismatched (issue, workspace) pair matches 0 rows in
the CTE, the dependent INSERT selects nothing, and the :one query returns
pgx.ErrNoRows — no mis-attributed comment is written and the issue is not
touched.
CreateComment is now the single carrier of the 'a comment belongs to an
issue in the same workspace and always bumps it' invariant, so no future
caller can break it by passing the wrong workspace. Signature unchanged;
no migration or foreign key.
Add TestCreateComment_WorkspaceMismatchPersistsNothing (error returned,
no comment persisted, updated_at unchanged).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): bound pre-start task preparation
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): isolate pre-start env preparation
Run execution-environment Prepare and Reuse in a killable helper process so a timed-out attempt cannot keep writing after retry. Add FIFO lifecycle and squad Stage retry regression coverage.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): terminate Windows prepare process trees
Assign the pre-start helper to a kill-on-close Job Object before releasing its request, wait for all job members to exit on cancellation, and add a Windows runtime regression job.
Co-authored-by: multica-agent <github@multica.ai>
* ci: target Windows prepare tree regression
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
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.
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>
Classify outbound replies by task origin (chat_input_task_id) before consulting a channel binding, so web/mobile direct-chat completions and failures stay inside Multica even when the session was originally created from Lark or Slack. Channel-created tasks continue to deliver. Closes#5644.
* 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>
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>
* 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>
* fix(daemon): isolate Linux Codex git metadata (MUL-4925)
Co-authored-by: multica-agent <github@multica.ai>
* refactor(daemon): address isolated-checkout review nits (MUL-4925)
- rename sameFilesystemPath -> sameResolvedPath (it compares resolved
paths for equality, not same-device), with a clarifying doc comment
- prune earlier tasks' agent/* branches when reusing an isolated
checkout so a long-lived reused workdir stops accumulating one local
branch per checkout; deleteLocalBranches now takes a keepBranch arg
and the prune is non-fatal
- cover the prune in TestCreateWorktreeReusesIsolatedGitMetadata
* fix(repocache): preserve user branches on reuse (MUL-4925)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): add v0.4.4 (2026-07-17) release entry
Adds today's user-facing changelog entry across en/zh/ja/ko: a new autopilot schedule editor, an interactive diagram viewer, plus the day's improvements and fixes.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): use 自动化 for autopilot in zh, matching the standard term
Per Bohan's review: the Chinese standard for autopilot is 自动化 (as used elsewhere in zh.ts, e.g. 定时自动化 / 触发式自动化), not 自动驾驶. Normalizes all 10 occurrences in the changelog, including the new v0.4.4 entry and earlier historical entries, for consistency.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): auto-retry transient provider stream cut like chat (MUL-4910)
Claude Code's "API Error: Connection closed mid-response" is a transient
network cut. In unattended issue runs it fell through to
agent_error.unknown / process_failure — neither in retryableReasons — so
the task terminated. Interactive chat only appeared resilient because the
CLI's own in-process retry usually recovers first; there is no
chat-specific retry in Multica. Both paths share the same
finalizeStreamResult -> Classify -> retryableReasons pipeline.
- classify: route "connection closed" / "mid-response" to
agent_error.provider_network, before the exit-status rule so the
"exited with error: exit status N" variant also lands here.
- retry: add provider_network to retryableReasons. It is resume-safe, so
the retry child inherits the session and continues the truncated
conversation instead of restarting.
Tests cover the new classification (incl. exit-status variant) and the
retry/resume flags. Note: mirror the substrings into the MUL-1949 offline
backfill SQL to keep in-flight and historical taxonomies aligned.
Co-authored-by: multica-agent <github@multica.ai>
* feat(task): defer provider_network's final retry ~5s — three-tier (MUL-4910)
Follow-up to the immediate-retry fix: make the connection-closed retry a
three-tier schedule — first run + immediate retry + one retry deferred ~5s —
so a blip that survives the immediate retry gets a short cooldown before the
final attempt instead of firing back-to-back.
Reuses the existing deferred/fire_at primitive (the comment-routing escalation
mechanism) rather than adding new infrastructure: CreateRetryTask gains an
optional fire_at; when set, the child is inserted 'deferred' and the existing
PromoteDueDeferredTasksForRuntime sweeper — already run promote-first on every
claim poll — flips it to 'queued' at fire_at. No migration, no claim-query
change, no daemon change.
- retryAttemptCeiling: raise provider_network's ceiling to 3 (other reasons
keep max_attempts=2); applied in both retryEligible and
MaybeRetryFailedTask's budget pre-check so the primary and sweeper paths agree.
- retryDelayForAttempt: only provider_network's final attempt is deferred (5s);
every other retry — including provider_network's first — stays immediate.
- FailTask + MaybeRetryFailedTask pass fire_at and skip the queued
broadcast/notify for a deferred child (promotion emits them at fire time).
Timing: the deferred child fires on the first claim poll at/after fire_at, so
>= 5s; on an otherwise-idle runtime it can stretch to the poll interval — the
same behaviour deferred escalations already have.
Tests: pure schedule/eligibility coverage (TestProviderNetworkRetrySchedule)
plus a DB test asserting fire_at controls deferred-vs-queued, attempt=3, and
resume-safety.
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): persist reason-aware retry budget; respect max_attempts=1 disable (MUL-4910)
Addresses the pre-merge review must-fix: retryAttemptCeiling raised
provider_network to 3 unconditionally, which (1) overrode the
max_attempts=1 "auto-retry disabled" contract and (2) persisted a
self-contradictory child (attempt=3, max_attempts=2) that leaks to the
task API, so a naive attempt < max_attempts consumer would misjudge the
budget.
- retryAttemptCeiling now returns taskMaxAttempts unchanged when it is <= 1
(disabled stays disabled) and only ever WIDENS otherwise (max(col, 3) for
provider_network) — a higher configured budget is kept.
- CreateRetryTask takes an optional max_attempts; FailTask and
MaybeRetryFailedTask write the reason-aware effective ceiling into the
child so the whole retry chain self-describes (attempt=3, max_attempts=3).
NULL inherits the parent column, so non-provider_network reasons are
unchanged.
Tests:
- pure: ceiling widens to 3, keeps a higher budget, and never revives a
disabled (max_attempts=1) task; eligibility rejects the disabled case.
- DB (end-to-end FailTask): default budget → deferred final child at
attempt=3/max_attempts=3; first failure → immediate child; max_attempts=1
→ no child. Plus CreateRetryTask persists the passed budget / inherits on NULL.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
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.
* 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>
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>
Self-hosted upgrades to v0.4.3 failed closed on migration 198's VALIDATE of the strict attribution constraint, because the legacy rows migration 190 exempted were only backfilled out-of-band on cloud. Registers a pre-198 preMigrationHook that idempotently mirrors originator_user_id into accountable_user_id in batches before VALIDATE, with FOR UPDATE + repeated predicate to avoid clobbering concurrently-written rows, so a stuck-at-197 instance auto-heals on migrate up with no manual SQL. Originator-NULL rows are left untouched. Verified with unit + concurrency + end-to-end tests against real Postgres.
Fixes#5544
* 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>
Agents were writing runtime-local paths into deliverables as clickable
links (`[screenshot](/Users/agent/work/shot.png)`). Two root causes, both
fixed here.
A. The brief never stated the delivery contract. Add an always-on delivery
invariant (outside writeOutput's kind switch, so no task kind can inherit
none) plus a per-surface file-delivery line for each of the five surfaces.
Chat splits into two: `attachment upload` works only on web/mobile chat,
never on an IM channel, so ChatChannelType is now threaded into
TaskContextForEnv.
The claim path only ever looked up Slack bindings, so a Feishu session
reported as a web chat and got upload guidance for a channel that cannot
carry attachments. Probe every channel type. The chat policy is two
independent layers and stays that way: delivery keys off "is there a
channel at all"; the `chat history` / `chat thread` commands stay
Slack-only because both endpoints are hardwired to h.SlackHistory and
there is no Feishu reader — ChatInThread only selects between those two
commands, so it stays Slack-only too.
Add a CLI hard-fail lint on `issue comment add` / `issue create` /
`issue update` as the enforcement backstop. Scoped narrowly, since a false
positive blocks a real deliverable: agent task context only (a human's PAT
run is untouched), real CommonMark link/image/autolink destinations only
via goldmark (a path in a code span or fence — how an agent quotes a path
it is discussing — is structurally invisible), and three high-confidence
signals only (`file://`, inside the workdir, or an existing local file).
A bare `/foo` is a valid origin-relative URI and is deliberately allowed.
`issue update` has no --attachment flag, so its hint redirects to
`comment add` rather than naming an argument it rejects.
B. Desktop presented the resulting router 404 as an unknown crash. 8 of 18
desktop_route_error reports were users clicking such a link and being told
the app broke and to file a bug. Split the 404 into a first-class Not Found
view: no crash framing, no Report error. Its recovery entry comes from the
tab store's active workspace, never from the failed pathname — deriving a
slug from `/Users/me/shot.png` yields "Users" and a button to `/Users/issues`,
a second 404.
Also add a will-navigate trusted-origin guard via the shared loadRenderer
(main + issue windows). This is origin hardening only, NOT the mechanism for
in-app links: client-side routing never fires will-navigate, so app paths
never reach it. Issue windows need no 404 work — their router only accepts
paths validated by parseIssueWindowPath and they do not listen for
multica:navigate, so a bad path cannot reach them.
Server-side completion observation is metric/log only and never blocks: it
is lexical (`file://` + task work_dir prefix) because the server cannot stat
the daemon's filesystem, and the metric label is a closed enum so no path or
reply text reaches Prometheus.
Verified: pnpm typecheck/lint/test (3582 tests), go vet, full Go suite
including new claim-path integration tests. cmd/multica was verified outside
the daemon workdir — inside one, 93 of its tests fail identically on
origin/main because the suite walks up and finds the runtime's own task marker.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
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>
* fix(cli): fail fast with login hint when starting daemon unauthenticated
'multica daemon start' (background mode) spawned the child first and
only then polled its health port. When the user never ran 'multica
login', the child died instantly on resolveAuth, but the parent kept
polling for the full 45s readiness window and ended with a vague
"check logs" warning and exit code 0 — looking like a silent hang.
Check the stored config token before spawning (mirroring
daemon.resolveAuth, which only accepts the config token) and exit
immediately with an actionable "run 'multica login'" hint.
'daemon restart' gets the same guard BEFORE its stop phase: it used
to stop the running daemon first and only then fail auth inside the
start phase, leaving the user with no daemon at all. The foreground
path already failed fast and is unchanged.
* fix(cli): report early daemon child exit with an actionable reason
Background 'daemon start' Release()d the child immediately and then
polled the health port blind. Any preflight failure — server
unreachable, stored token rejected with 401 — killed the child within
a second, but the parent still sat through the full 45s readiness
window and ended with a vague "check logs" warning and exit code 0.
Keep a Wait() goroutine on the child and select on it inside the
readiness poll. When the child dies before reporting ready, classify
what this startup attempt appended to the log and fail with exit
code 1 and a one-line reason plus next step:
- token rejected / 401 -> run 'multica login' (profile-scoped)
- connection refused / DNS / timeout -> server unreachable at <url>
- anything else -> short log excerpt with DBG/INF noise dropped
* fix(cli): probe token validity and server reachability before restart stops the daemon
requireDaemonAuth only rejects an empty stored token, so a revoked or
expired token — or an unreachable server — passed the restart guard,
the running daemon was stopped, and the replacement child then died in
preflight, leaving no daemon at all (#5165).
daemon restart now runs a whoami round-trip (same /api/me call as
'multica auth status') against the server the daemon will talk to,
using the stored token, before entering the stop phase — and only when
a daemon is actually running, so plain 'daemon start' keeps its
zero-round-trip happy path. On 401 it reports the re-login hint; on a
transport error it reports the unreachable server; both state that the
running daemon was left untouched.
Regression tests cover a non-empty stored token against a fake 401
server and an unreachable server, asserting /shutdown is never
requested on the fake running daemon.
Squad-leader follow-ups on the same issue now reuse the prior daemon-managed workdir and provider session instead of starting fresh, while never binding or locking a user-provided local_directory. Reuse eligibility is keyed off a Prepare-time .managed_env.json provenance marker, so it does not race the completion→GC-metadata write.
Closes#5535
Co-authored-by: Bohan <bohan@devv.ai>
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>