* feat(settings): improve API tokens page guidance and token-created dialog
The tokens page never told users what to do with a created token, and
the created dialog was easy to dismiss before the token was saved.
Page:
- Describe how a token is used (multica login --token, Bearer for API)
- Add a security note (full account access, treat like a password)
- Add an empty state instead of rendering nothing
Token-created dialog:
- Clearer title and an info callout emphasizing the token is displayed
only once
- Ready-to-copy CLI sign-in command (multica login --token <token>)
- "I have securely stored this token" checkbox gating the Done button
- Wider dialog (sm:max-w-xl); token and command render on one line and
truncate with an ellipsis (copy buttons still copy the full value)
All four locales (en, zh-Hans, ja, ko) updated.
* fix(settings): show load-failed state instead of empty card when token fetch fails
* fix(settings): add aria-labels to token dialog copy buttons
A machine-level rename stamps the same custom_name on every runtime of a
daemon (MUL-4217), so the settings-page runtime dropdown rendered N
indistinguishable machine-name rows. Split selection into two levels:
pick the machine first, then a runtime on it, labelled by the runtime
itself. Opening lands inside the selected runtime's machine, and the
trigger now reads 'Claude · <machine>' instead of the bare machine name.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The in-page find bar (absolute, z-20) and the resolve collapse bars
pinned at the timeline's top-0 (sticky, z-20) tied on z-index, so the
later-in-DOM collapse bar painted over the find bar, half-hiding it and
orphaning its close button. Raise the find bar to z-30 so the transient
overlay reliably paints above every sticky affordance in the content
column (comment headers z-10, collapse bars z-20).
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
On platforms where scrollbars take layout space (macOS with a mouse or
'always show', Windows, Linux), the global 'scrollbar-width: thin'
reserves ~11px on the right edge of the issue detail scroll container
only, so the centered max-w-4xl column reads 32px left vs 43px right
whitespace. Mirror the gutter with 'scrollbar-gutter: stable both-edges'
on the detail scroller and its loading skeleton; overlay-scrollbar
platforms reserve nothing and are unchanged.
Measured before: 32px / 43px. After: 43px / 43px.
Co-authored-by: Lambda <lambda@multica.ai>
Dropdown, context menu, select, and popover surfaces used
--floating-shadow, which is sized for window-level overlays and reads
too heavy on trigger-anchored menus. Introduce a lighter --menu-shadow
tier (surface < menu < floating) and drop the shadow-lg override on
ContextMenuSubContent so submenus match their parent menu. Dialogs and
sheets keep --floating-shadow.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The inbox row used px-3 inside the px-2 list container (20px content
inset) while the chat thread row uses px-2 (16px), so switching between
the Chat and Inbox pages made the avatar column visibly jump. Align the
inbox row to px-2, which also lines the avatars up with the shared
PageHeader title (px-4 = 16px) and matches the row convention used
elsewhere. Update the two loading-skeleton rows (px-4) to match so the
list no longer shifts when real rows load in.
Closes MUL-4396
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The aggregate chat unread badge used two different definitions: web/
desktop's sidebar summed unread messages while mobile's tab badge (and
the since-removed ChatFab badge it mirrored) counted sessions — the same
account state showed e.g. 5 on web and 2 on iOS. The sidebar also summed
ALL sessions while the thread list zeroes the row being viewed, so a
reply landing in the open conversation flashed a sidebar count with no
matching row.
- packages/core/chat/unread.ts: countUnreadChatMessages() as the single
shared definition (IM-style message total, optional viewed-session
exclusion); pure so mobile can import it.
- app-sidebar: use the helper and exclude the actively-viewed session,
but only while a chat surface is actually showing it (chat route or
floating window open) — a remembered selection with both surfaces
closed still counts, since nothing will auto mark-read there.
- mobile: tab badge switches to the shared message count (99+ cap like
the sidebar), ChatSessionSchema parses unread_count, and markRead's
optimistic patch zeroes unread_count alongside has_unread.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): add thread quick-jump minimap to issue detail (MUL-4389)
A Linear-style rail of tick marks overlaid on the left edge of the issue
detail scroll area, one tick per comment thread (folded resolved bars
included). Ticks whose thread intersects the viewport render darker, so
the rail doubles as a scroll minimap. Hovering a tick grows it and opens
a preview card (bold first line + muted body excerpt, both clamped);
clicking jumps the timeline to the thread and flashes it like an inbox
deep-link landing.
Jumps go through Virtuoso's scrollToIndex in virtualized mode (the
target row may be unmounted) and direct container scrollTop math in the
flat deep-link/find modes, never native scrollIntoView (#3929).
Viewport tracking reads DOM rects on scroll/resize instead of an
IntersectionObserver because Virtuoso mounts/unmounts rows while
scrolling. Hidden on mobile: no hover, and the gutter is too tight.
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): Dock-style hover wave on the thread minimap (MUL-4389)
Hovering the rail now magnifies ticks with a cosine falloff of their
distance to the cursor — the hovered tick peaks at 1.7x and neighbours
taper off across ~4 tick pitches, following the pointer continuously.
Driven per-pointermove with direct style writes on the native `scale`
property (compositor-friendly, no React re-render), batched
read-then-write inside one rAF; a 100ms ease-out transition smooths
between pointer samples and settles the collapse on leave. Clearing the
inline value hands control back to the CSS floor states (popup-open,
focus-visible), and prefers-reduced-motion swaps the wave for a plain
hover grow. Only the hovered tick darkens — neighbours grow but keep
their color.
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): single glide-follow preview card on the thread minimap (MUL-4389)
Scanning the rail continuously re-paid the 150ms open delay plus the
close/open animation on every tick crossed, because each tick owned an
independent PreviewCard popover — hover felt laggy while gliding.
Replace the per-tick popovers with ONE card owned by the rail, driven
by the same rAF rect pass as the hover wave: the intent delay is paid
once when the pointer enters the rail; after that, gliding retargets
the card instantly (~1 frame) and slides it to the hovered tick with a
150ms transform transition. Leaving starts a grace timer long enough to
travel onto the card (which keeps it open for text selection); keyboard
focus anchors the card immediately. The anchor is clamped so the card
never sticks out of the column at the rail's extremes, and previews are
cached per thread content so unrelated timeline updates don't
re-flatten every comment.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The bare-Enter submit path already refuses to fire while view.composing
is true, but Mod-Enter had no such guard. Pressing ⌘↵ while a pinyin/kana
composition is still open submits the document WITHOUT the composed text
— e.g. paste a screenshot, type a Chinese sentence, hit ⌘↵ before the
buffer commits, and the submission carries only the screenshot. Apply the
same composing guard to Mod-Enter.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The sidebar Runtimes nav item showed a red dot (introduced in #533)
whenever the current user owned a local, CLI-launched runtime whose
reported cli_version was older than the latest GitHub release. Remove
the dot and the now-unused useMyRuntimesNeedUpdate hook.
The per-runtime update indicator on the Runtimes page
(useUpdatableRuntimeIds) is unchanged.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The floating chat bubble's unread-count badge duplicated the chat tab's
unread indicator in the sidebar and visually overlapped it. Drop the badge;
the hover tooltip still communicates unread/running state.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)
Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:
- agent_task_queue.chat_input_task_id makes a task the owner of the user
messages it must consume; the send path creates the task + user message +
attachment bindings + session touch in one transaction, and the daemon is
notified only after commit. A claim reads exactly that batch, so a message
that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
transaction: a normal message, or a visible no_response outcome (with a
non-empty English fallback) when the final output is empty. The write failing
rolls the completion back and the handler returns 5xx so the daemon retries;
the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
(keeping the tool timeline), suppress Copy, keep it unread, and keep the
session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
selector, so a rolling deploy never replays Slack/Lark history.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)
Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
the prior behavior: empty output writes no assistant row, so chat:done carries
empty content and the channel outbound silently drops it — the no_response
fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
and preserves the dispatched task for redelivery instead of cancelling a valid
task on a transient DB error.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Discover the Codex model list and per-model reasoning efforts from the installed CLI (codex debug models --bundled), with a verified static fallback for old/offline installs. Server gates token syntax; the daemon validates the exact (model, effort) pair.
Closes#5197
MUL-4354
Track actual claim-time delivery, support legacy daemons, and repair comment
batches across claim, retry, edit, and delete races.
MUL-4348
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Follow-up to #5188 addressing the second-round review.
- ValidateThinkingLevel now fails an empty codex model closed instead of
borrowing the flagged Default (gpt-5.6-sol). An empty model follows
config.toml, which can resolve to any installed model; Sol alone advertises
`ultra`, so the old borrow green-lit levels Luna / gpt-5.5 don't support and
Codex doesn't reject. Checked before ListModels so a discovery error can't
fail it open. Frontend pickModelEntry mirrors this (no per-model effort
preview for an empty codex model); the persisted-orphan clear path stays.
- parseCodexDebugModels drops efforts without a known label so the picker
never advertises a level the Create/Update enum gate would 400 on save; the
contract test now drives the real parser with an unknown effort instead of
comparing two hand-written maps.
- gpt-5.6 price aliases anchor to a literal dot (not the [.-] class), so
dashed variants like gpt-5-6-luna surface as unmapped on both backend and
frontend rather than silently borrowing a tier.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(models): add Codex gpt-5.6 series (sol/terra/luna) to model list & pricing (MUL-4347)
Co-authored-by: multica-agent <github@multica.ai>
* fix(models): official gpt-5.6 pricing, exact aliases, max/ultra effort levels (MUL-4347)
- Replace provisional gpt-5.6 rates with OpenAI's official announcement
values (sol 5/30, terra 2.5/15, luna 1/6); cache read 0.1x input, cache
write 1.25x input (frontend + backend, kept in sync).
- Anchor gpt-5.6 price aliases to exact match so unknown suffixed variants
surface as unmapped instead of borrowing a tier.
- Add Codex 0.144.1 max/ultra effort levels to the label map and server
enum so the daemon-advertised catalog matches what the API can persist;
add a catalog->API contract test.
- Clarify that the codex Default flag is the effort-validation anchor, not a
user-facing badge.
- Note the cache-write measurement limitation (codex usage stream doesn't
report cache-write tokens yet).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Batch sub-issue status changes triggered two wrong behaviours from one user
action:
- Frontend popped the pre-trigger "现在开始处理?" confirm modal for every
non-backlog target, but done/cancelled can never start a run, so it degenerated
into a misleading "won't start → OK" step. handleBatchStatus now applies
directly (product decision: batch status, including backlog → active promotion,
applies like a single-issue/CLI change). Assign agent/squad and delete still
confirm. The now-unreachable status mode is removed from RunConfirmModal and
its locale keys.
- Backend evaluated the stage barrier per-child inside the batch loop, using a
mid-batch sibling snapshot. A batch closing several stages at once emitted one
comment per intermediate stage, pinned the parent assignee's wake to a stale
"advance Stage N+1" instruction (the accurate wake was swallowed by the
pending-task dedup), and the outcome depended on issue_ids order.
BatchUpdateIssues now collects terminal transitions and evaluates each parent
once against the batch's final state (notifyParentsOfBatchChildDone): at most
one accurate comment + one wake per parent, order-independent. Single-issue
UpdateIssue is unchanged; WillEnqueueRun is untouched.
Tests: cross-stage batch done/cancelled (forward + reverse) and lower-stage-only
on the backend; status-direct / assign-confirm / delete-confirm routing on the
frontend.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(ui): converge ActorAvatar size to semantic tiers (MUL-4277)
Replace the free-form numeric `size` on ActorAvatar with a constrained
`AvatarSize` union (xs/sm/md/lg/xl/2xl) so avatar dimensions are chosen by
role instead of ad-hoc pixels. This eliminates the magic-number drift where
the same role rendered at different sizes across pages.
- Add `@multica/ui/lib/avatar-size` (AvatarSize union + AVATAR_SIZE_PX map +
default tier).
- Base `ActorAvatar` (packages/ui) and business `ActorAvatar`/`AgentStatusDot`
(packages/views) now take `AvatarSize`; internal font/icon math and the
presence-dot threshold read px from the map.
- Migrate all web/desktop call sites (packages/ui + packages/views) from
numeric sizes to tiers using the role table
(12,14->xs 16,18,20->sm 22,24,28->md 30,32,34->lg 40,44->xl 56,64->2xl).
- Token-ise the derived consumers `AgentAvatarStack` and
`IssueAgentActivityIndicator` (px looked up internally for overlap/+N math).
Out of scope (per plan): ui/avatar.tsx primitive, account-tab/AvatarPicker,
mobile, and component-name disambiguation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): unify all avatars and the upload cropper to round (MUL-4277, MUL-4184)
main's avatar-shape decision rendered non-human actors (agent, squad,
system) and the workspace logo as rounded squares, and the upload cropper
mirrored that with a square crop window. Per the updated decision
(avatars_and_cropper_round_required), every avatar and the crop UI are now
circular; the square path is removed rather than left as dead config.
- Base ActorAvatar: always rounded-full (drop the isHuman/rounded-md split).
- avatar-crop-dialog: remove the AvatarCropShape/square path; crop window is
always cropShape="round".
- avatar-upload-control: drop VARIANT_SHAPE; the control is always round and
no longer threads a shape to the dialog (variant still drives the fallback).
- Strip rounded-md/rounded-none square overrides from agent/squad/member
ActorAvatar call sites; round the read-only agent/squad static wrappers.
- WorkspaceAvatar: round the org logo so it matches the (now round) workspace
upload/crop and the shared avatar shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(ui): make round avatar shape a hard invariant (MUL-4277)
Close the two remaining square squad-avatar paths flagged in review and
prevent call sites from re-squaring the avatar:
- base ActorAvatar: keep `rounded-full` as the last class in cn() so a
call-site `className` can no longer override the circle.
- SquadHeaderAvatar: drop `className="rounded"` (was overriding the base
circle into a small rounded square).
- SquadsPage no-avatar fallback: route through the shared ActorAvatarBase
(`isSquad size="lg"`) instead of a hand-written rounded-md tile, so the
fallback matches the image path — one shape source of truth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): round the agent/squad avatar loading skeletons (MUL-4277)
The avatar placeholder skeletons on the agent/squad list, detail, and
profile-card loading states were still rounded squares (rounded-md/lg) from
the pre-round era, so the avatar visibly popped from square to circle on
load — inconsistent with the round avatars and with the member/inbox/issue
skeletons that already use rounded-full.
Round all five: agents-page, agent-detail-page, squads-page,
squad-detail-page, squad-profile-card.
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>
* feat(editor): Linear-style issue identifier autolink (MUL-4241)
Bare issue identifiers (e.g. MUL-123, TES-1) now render as navigable issue
chips and can be typed/pasted into a real mention, instead of staying inert
text. Covers Phase 1 (readonly render) and Phase 2 (editable editor); the
Phase 3 batch resolve API is intentionally deferred.
Phase 1 — readonly render autolink
- Pure, markdown-aware detector `preprocessIssueIdentifiers` in
@multica/ui/markdown rewrites bare identifiers to
`[MUL-123](mention://issue/MUL-123)`, skipping code, existing links,
URLs, and file/path tokens. Runs before linkify/file-card.
- `isIssueIdentifier` distinguishes a bare identifier from a real mention
UUID at render time (a UUID never matches the identifier pattern).
- Chat markdown and comment/description readonly both resolve identifiers
to a real issue via a workspace-scoped, exact-match TanStack Query
(`issueIdentifierOptions`), rendering a chip on a hit and plain text on a
miss / cross-workspace / while loading. The exact `identifier ===` filter
enforces the workspace prefix, since the backend search matches by number.
- Autolink is opt-in per surface; the shared editable preprocess pipeline is
untouched so editable content is never rewritten with fake mentions.
Phase 2 — editable editor input/paste
- Async ProseMirror plugin resolves a completed identifier (boundary typed
after it, or found in pasted text) and swaps it for an issue mention node,
serialising to canonical `[MUL-123](mention://issue/<uuid>)`. Only genuine
user edits seed candidates (programmatic setContent is gated), so opening
existing content never rewrites it. Resolver injected from the setup layer;
no React hooks inside the extension.
Tests: detector (code/link/url/path skips, dedupe), core resolver (exact
match, wrong-prefix miss, empty response, key shape), chat + readonly render
(hit/miss/code/canonical), and the editable extension (type/paste/miss/
inline-code/mount-safe/incomplete-token).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): scope Phase 2 autolink to the captured candidate range (MUL-4241)
Howard final-review blocker: the async resolve rescanned the whole document
for every occurrence of the resolved identifier, so completing a new `MUL-1`
also rewrote a pre-existing `MUL-1` the user never touched (persisted-content
rewrite; violated "opening existing content is not rewritten").
Fix: capture the specific candidate range(s) a user transaction introduces —
the token before the caret when typing, the tokens inside the pasted slice on
paste — into plugin state, mapping each range forward on every subsequent
transaction. After async resolve, replace ONLY those mapped ranges, verifying
each still holds exactly that identifier with intact boundaries and no
code/link mark. No document-wide scan by identifier. Also skip link-marked
text at capture so an existing link label is never converted.
Regression tests: (1) typing a new MUL-1 converts only the new occurrence,
not a pre-existing identical one; (2) paste converts identifiers inside the
paste range but leaves an identical one outside it untouched; (3) an
identifier already carrying an explicit link mark is not replaced.
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>
* feat(views): unify avatar upload with crop editing across web/desktop
Add a shared AvatarUploadControl + AvatarCropDialog used by the user,
workspace, agent, and squad avatar entry points. Cropping (pan/zoom, fixed
1:1) and compression run client-side on canvas; the existing /api/upload-file
+ avatar_url chain is reused unchanged (no backend/API/DB changes). This
collapses four hand-rolled upload buttons into one control and removes
AvatarPicker.
Also make the shared display avatar treat all non-human actors (agent, squad,
system) as rounded squares — completing the "circles are for humans"
convention the editors already assumed, so display and editors agree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): rebuild avatar cropper to the "Edit avatar" reference form
Replace the hand-rolled canvas cropper with react-easy-crop to match the
requested design: full-bleed image with a dimmed overlay outside a bright
crop window, a rotate control, a zoom slider flanked by −/+, and a
Reset / Cancel / Save footer. Round window for people, rounded-square for
non-human actors; output stays a 512px square (webp, jpeg fallback) through
the same upload/avatar_url chain.
avatar-crop.ts keeps the encode pipeline and gains rotation-aware
getCroppedAvatarBlob; the interactive geometry now lives in react-easy-crop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): round square avatar crop frame
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Starting a new chat (⊕ new chat on the Chat tab, or ⊕ / switching agent
in the floating window) now pulls keyboard focus into the compose box so
the user can type immediately, instead of having to click into it first.
Focus is driven by a monotonic `focusRequest` nonce bumped only by the
new-chat handlers, so selecting an existing chat or opening one via a
`?session=` deep link never steals focus. ContentEditor.focus() latches
through to onCreate when the editor is not yet mounted (immediatelyRender:
false), so the freshly-mounted compose box focuses on its first frame.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Supersedes the read-only gfm-autolink approach (#5091), which split URL
linkification across two engines: the editor kept the string preprocessor
(urls:true) while the read-only renderer let remark-gfm autolink (urls:false)
plus a remark-cjk-autolink plugin. gfm autolink still swallowed the closing
`**` into the href whenever a CJK punctuation immediately followed
(`**url**(MUL)`), so bold-wrapped URLs stayed broken in Chinese prose.
Fix it once, at the shared string layer: collectLinkifyMatches now drops a
trailing run of markdown delimiters (`*`, `~`) from each URL match, so
`**url**` yields a clean `**[url](url)**` and the emphasis closes. Editor and
read-only share preprocessMarkdown / preprocessLinks again — one linkify logic,
no renderer-specific machinery.
- linkify.ts: trailing-delimiter strip in collectLinkifyMatches; CJK rescan is
keyed off the terminator index, independent of the trim.
- Remove the urls:false split (detectLinks / preprocessLinks / preprocessMarkdown)
and delete the remark-cjk-autolink plugin.
- Tests: **url**, **url**(CJK, CJK multi-URL, explicit link untouched, and the
trailing-* tradeoff.
Known tradeoff: a bare URL that genuinely ends in `*` (e.g. a glob) has the `*`
dropped from the link — identical to GitHub's autolink, locked by test.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
MUL-4261 surfaced cancelled issues only when the status filter explicitly
selected "cancelled": a separate BOARD_STATUSES (six statuses, cancelled
excluded) plus a runtime showCancelled gate hid cancelled from the default
list/board/swimlane. That is the wrong product model — cancelled is a
lifecycle state in the same category as todo/in_progress/done/blocked and
should be a first-class default column.
- Remove BOARD_STATUSES. Its only purpose was to exclude cancelled, which
this change reverses. PAGINATED_STATUSES is now ALL_STATUSES; the surface's
default visible/hidden status derivation, the assignee-grouped board's
default status set, and the swimlane column fallback all use ALL_STATUSES.
- Remove the `bucketedIssues.filter(status !== "cancelled")` gate in the
surface data layer. Cancelled flows through to list/board/swimlane columns,
header facet counts, batch selection, and isEmpty like every other status.
- hiddenStatuses derives from ALL_STATUSES, so cancelled participates in the
board show/hide controls consistently (hideStatus already used ALL_STATUSES).
The status filter now narrows the visible set instead of unlocking an
otherwise-hidden bucket. Cancelled renders last (its canonical ALL_STATUSES
position). Mobile keeps its own status mirror and is out of scope.
Regression tests updated: controller now asserts cancelled is a default
visible status, the filter narrows (and can hide cancelled), swimlane renders
the Cancelled column by default and drops it only when the filter narrows past
it, and the assignee board fetches cancelled by default.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): surface cancelled issues via status filter (MUL-4261)
Cancelled issues were never visible in the web/desktop issue surface:
`PAGINATED_STATUSES`/`BOARD_STATUSES` excluded `cancelled`, so the list/
board/swimlane never fetched or rendered it, and the status filter offered
a "Cancelled" checkbox that resolved to an empty list.
Implement plan A (fetch-always, hide-by-default):
- `PAGINATED_STATUSES` now includes `cancelled`, so it is always fetched
into the byStatus cache and rebuckets correctly when an issue is
cancelled (previously the card was dropped). `BOARD_STATUSES` stays the
default *visible* column set.
- The surface gates the flattened list on the status filter: cancelled
issues are excluded from `surfaceIssues` (and therefore list/board/
swimlane columns, header facet counts, batch selection, and isEmpty)
unless the filter explicitly selects "cancelled". Then a Cancelled
section appears, sorted last.
- `hiddenStatuses` stays board-only, so cancelled is never offered as a
hideable/persistent board column.
Dragging a card into the Cancelled column (visible only when filtered)
sets status=cancelled through the existing generic column DnD — no new
entry point or copy added.
Non-goals (unchanged): mobile, member/agent archive surfaces, an
always-on cancelled column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): swimlane must keep the cancelled column when filtered (MUL-4261)
The swimlane derived its status columns as
`BOARD_STATUSES.filter(s => visibleStatuses.includes(s))`, re-imposing
canonical order by intersecting with BOARD_STATUSES. Since BOARD_STATUSES
omits `cancelled`, a filter-selected Cancelled column was silently dropped
even though the controller's `visibleStatuses` included it — the surface
fetched and gated cancelled correctly, but swimlane never rendered it.
Filter against ALL_STATUSES instead: same canonical ordering, but a
selected `cancelled` column now survives. `hiddenStatuses` stays
board-only, so cancelled is still never a hideable/persistent column.
Regression tests:
- swimlane renders a Cancelled column + its cards when cancelled is in
visibleStatuses, and omits it otherwise (verified failing pre-fix);
- controller asserts hiddenStatuses never contains cancelled.
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>
* fix(markdown): autolink read-only URLs in the parse tree, not raw text
Read-only markdown surfaces (comments, descriptions, chat) pre-linkified
bare URLs by rewriting the raw source to [url](url) before parsing. Because
linkify-it treats `*` as a valid URL character, a bare URL followed by a
bold close — `**PR:https://…/5081**` — had the trailing `**` swallowed into
the match and rewritten as [url**](url**). That consumed the emphasis closer
(the bold never closed; the leading `**` rendered as literal asterisks) and
corrupted the href with a trailing `**` (MUL-4242).
Let remark-gfm autolink URLs in the parse tree instead, where emphasis is
already resolved so an adjacent delimiter can never be absorbed. The custom
string pass now runs in a `urls: false` mode on read-only surfaces and only
linkifies file paths (which gfm never does). A small remark plugin
(remark-cjk-autolink) re-applies the existing CJK URL boundary to gfm's
autolink literals so `https://x/a。后面` still stops at 。.
The Tiptap editor path is unchanged (`urls: true`): @tiptap/markdown does not
autolink bare URLs, so it still needs the string pass.
Note: read-only URL autolinking now follows GFM semantics (scheme, www., or
email required); bare fuzzy domains like `NBA.com` render as plain text on
read-only surfaces, matching CommonMark/GFM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): keep every URL in a CJK-separated run linked in readonly
Follow-up to the read-only autolink fix. remark-gfm glues `url1、url2` into a
single autolink literal because it treats CJK punctuation as a URL character;
remark-cjk-autolink trimmed only at the first terminator and dropped the tail
to plain text, so the second URL stopped being a link — a same-class regression
of MUL-4242 for CJK-punctuation-separated URLs (flagged in review).
Re-derive the segments with detectLinks (which reuses collectLinkifyMatches'
truncate-and-rescan) and rebuild the [link, text, link, …] sequence, so every
URL in the run stays linked. Adds a read-only test for
`两个地址 https://a.com/x、https://b.com/y`.
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>
* fix(core): add exponential backoff with jitter to WSClient reconnect
The WebSocket client used a flat 3-second reconnect delay with no
backoff, jitter, or attempt limit. When the server restarts, every
connected client (web + desktop) reconnects at exactly T+3s, creating
a thundering-herd connection spike.
Replace the fixed delay with exponential backoff:
- Base delay 1 s, doubling each attempt (1 → 2 → 4 → 8 → …)
- Cap at 30 s to keep recovery time reasonable
- ±20 % jitter to decorrelate clients that disconnect simultaneously
- Give up after 20 consecutive failures (log error, allow manual retry)
- Reset the counter on successful authentication
Add 7 unit tests covering the backoff curve, cap, jitter range,
counter reset, max-attempt cutoff, and disconnect cancellation.
Closes#5035
* fix(core): clamp jittered delay to max and make jitter test deterministic
Address Copilot review feedback:
1. Clamp the final delay to RECONNECT_MAX_DELAY_MS after jitter is
applied. Previously, when base was already at the 30s cap, +20%
jitter could push the delay to 36s, violating the configured max.
2. Replace the nondeterministic jitter test (which relied on real
Math.random() producing ≥2 distinct values in 20 samples) with a
deterministic stub that alternates between 0 and 1, asserting
exact min/max delays (800ms and 1200ms).
* fix(core): remove reconnect attempt limit, retry indefinitely with capped backoff
Address maintainer feedback (NevilleQingNY):
The web/desktop UI does not currently expose a visible disconnected
state or manual retry action, so the 20-attempt give-up limit would
leave an open tab silently stale after a long outage. Remove the
limit and let the client retry indefinitely with the 30s capped
jittered delay.
- Drop RECONNECT_MAX_ATTEMPTS constant and the give-up early-return
- Update JSDoc to document the indefinite-retry contract
- Replace "stops after max attempts" test with "keeps retrying
indefinitely with capped delay" that verifies 25+ attempts still
schedule reconnects at 30s
Chat list avatars read too large at 36px; inbox list was 28px, so the two
surfaces were inconsistent. Standardize both to 32px (the common list-row
avatar size) — chat list 36->32 (fallback placeholder size-9->size-8),
inbox list 28->32.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The agent profile hover card now surfaces the runtime-native model id
(mono, e.g. `claude-opus-4-8`) with the reasoning/effort token as a badge,
so a quick hover answers "which model is this agent running?" without
opening the detail page. Empty model renders a "Runtime default" placeholder.
Also fixes the Skills row: chips wrap to multiple lines, so the vertically
centered label drifted to the middle chip — it now pins to the first chip
row (items-start + pt), and each chip truncates so a long skill name can't
blow out the card width.
The effort badge is gated on `effort` alone (not `hasModel`): an agent with
no pinned model can still persist a thinking_level override that applies at
run time, and hiding it would misreport the agent's real config. Adds
agent-profile-card.test.tsx covering the model/effort render states,
including the `model:"" / thinking_level:"high"` regression.
New i18n: profile_card.model_label / model_unset (en/zh-Hans/ja/ko).
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): advance selection to next chat when archiving the open session
Archiving the chat currently open in the two-pane Chat tab left the
conversation pane showing a now read-only, dangling session. Mirror the
Inbox list's handleArchive: move selection to the next chat in the
sorted, non-archived history list, fall back to the previous one, and
clear only when nothing is left. Archiving a non-open row is unchanged.
Closes MUL-4278
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): route archive-advance through the shared controller
Address review of #5110:
- Advance now routes through handleSelectSession so selectedAgentId stays
in sync when the next chat belongs to a different agent (a follow-up
"new chat" no longer defaults to the archived chat's agent).
- Move the advance/next-prev/clear logic into use-chat-controller
(advanceSelectionAfterArchive + archiveSession) and drive both Chat-tab
entry points from a single ChatPage.handleArchive: the thread-list row
AND the conversation header ⋯ menu (the header previously only flipped
status, stranding the user on the archived read-only conversation).
- Mobile: archiving the open fullscreen conversation returns to the list.
- Floating window: archiving the open chat now advances to the next chat
(with cross-agent sync) instead of clearing, matching the Chat tab.
Tests: controller test covers advance/fallback/clear/no-op + cross-agent
sync; thread-list test now asserts it delegates to onArchive.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Tiptap's Placeholder only reads its text at mount, and ContentEditor had a
defaultValue-sync effect but no placeholder-sync effect. Switching between
sessions of the same agent doesn't remount the editor, so the placeholder
froze on the previous value — e.g. stuck on "This session is archived" after
visiting an archived session, even on an active, usable input.
Mutating the extension's string option at runtime does not repaint (Tiptap
snapshots a string placeholder at mount). A function placeholder, however, is
re-invoked on every decoration pass, so:
- extensions: `placeholder` option now accepts `string | (() => string)`.
- content-editor: pass a getter over a live `placeholderRef`; a new sync effect
updates the ref and dispatches an empty transaction (docChanged=false, no
onUpdate loop) to force a decoration recompute — no remount required. This
also fixes placeholder staleness when the session/agent archive state changes.
- chat-input: update the editorKey comment (placeholder no longer relies on the
agent-switch remount).
- tests: getter reads the live value + one repaint on change; no repaint when
the placeholder prop is unchanged.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)
Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).
Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
session selection; web + desktop routing wired up. Removes the old
chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
unread *count* badge (read-cursor model), presence-gated typing vs waiting.
Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
empty state (avatar + name + description + starter prompts), and a
deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
(migration 146, dedicated chat_pinned_agent table + handler/queries).
New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
static template).
- Design: fade the global --border token; borderless list headers on
Chat/Inbox, kept (faded) on the conversation header.
Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)
The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.
Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.
- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)
* feat(chat): Settings toggle for the floating chat window (MUL-4235)
Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.
- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.
typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)
Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).
- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
revert the Preferences chat section and its test mock; store tests updated
for the opt-in default.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)
The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").
The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).
Based on the Chat V2 branch (parent MUL-4171, #5076), not main.
Co-authored-by: Lambda <lambda@multica.ai>
* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)
Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.
Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.
Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.
Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.
* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)
Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).
MUL-4253
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)
The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.
Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address review findings + unbreak CI (MUL-4171)
- task:failed now refreshes the sessions list (invalidateSessionLists), so
the thread-list preview / unread / sort stays correct after an agent
failure — FailTask persists a failure chat_message but only broadcasts
task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
?session= id isn't in it (deleted / no access / never existed) with nothing
in flight, clear the selection instead of rendering an editable empty chat
that would POST into a nonexistent session. Freshly-created sessions are
exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
expectedSegments) — keeps the two in sync, fixes the failing @multica/core
test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
EmptyState.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)
Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.
- Move the self-heal into the shared useChatController so every surface (tab
and floating window) drops a dangling activeSessionId once the sessions list
has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
or is a just-created session still awaiting the refetch; a dangling id falls
through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
(hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
— a session deleted elsewhere with real cached history stays eligible for
self-heal. Add a unit test for the discriminator.
Co-authored-by: multica-agent <github@multica.ai>
* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)
The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.
- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
unread_count, and hides the badge when all sessions are read — so this drift
is caught next time.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)
Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.
- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
original window renders (V2 had dropped them); drop the now-unused
window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
window, gated by floatingChatEnabled (default off) and hidden on /chat.
typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)
Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
chats; hard delete lives only there (hover -> unarchive + delete, with
the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
chats unarchive/delete.
Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)
* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)
Re-review follow-up on the restored floating ChatWindow + archive flow:
1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
shared controller, so its ensureSession trusted any non-empty
activeSessionId and there was no dangling-session cleanup — a deleted /
no-access persisted session could send into a nonexistent session. Ported
the same guard used for the tab: a self-heal effect that clears a dangling
activeSessionId once the sessions list has loaded, and ensureSession only
trusts an id that's in the list or has an in-flight optimistic write
(hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
the optimistic message + pending task before setActiveSession, so a
freshly-created session is never mis-cleared.
2. Floating dropdown bypassed archive-first safety. Its active rows offered a
hard-delete, letting the floating window destroy active chats and skip the
"archive first, delete only from Archived" model. Active rows now ARCHIVE
(reversible, one-click) like ChatThreadList; the floating window offers no
hard-delete — unarchive/delete live only in the full Chat page's Archived
view (reachable via expand). Removed the now-dead delete-confirm machinery.
3. Orphan user message on archived-agent send. SendChatMessage created the
chat_message before EnqueueChatTask, which rejects an archived / runtime-less
agent — a stale client would land a user message then get a 500, orphaning
it. Added a preflight that checks the session agent's archived / runtime
state and returns 409 before any mutation, plus a handler test asserting the
send is rejected with no message persisted.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): make rename a machine action + fix picker search copy (MUL-4217)
Follow-up to the runtime-naming feature based on testing feedback:
1. The create-agent runtime picker filters by MACHINE (its search matches the
machine title / host / provider names), but the placeholder said "Search
runtimes", which misled users into typing a runtime name. Change the
placeholder and the empty-state to "Search machines" / "No matching
machines" (all 4 locales).
2. Rename was framed as "rename this runtime" with an opt-in "apply to whole
machine" checkbox, but the intent is naming the machine (the computer), not
an individual runtime. Rework it into a machine-level action:
- New RenameMachineDialog always names the whole machine (apply_to_machine);
the per-runtime dialog + checkbox are gone.
- The entry now lives on the Runtimes page, on the selected machine's header
(a pencil next to the machine title), owner/admin gated — that's where the
user looked for it. Removed the per-runtime pencil from the runtime detail
page.
No backend change — apply_to_machine and machine-name inheritance already exist.
Verified: pnpm typecheck (all apps), full views suite (1650) + locale parity,
lint (0 errors).
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): always show machine header in picker; pre-fill rename with shared name only
Testing + review follow-ups on #5087:
1. The create-agent picker hid the machine group header when there was only
one machine (e.g. after a search narrowed to one), collapsing to a flat
list. Always render the machine header so grouping stays consistent.
2. (Elon) RenameMachineDialog pre-filled from the first non-empty custom_name
on the machine, but the machine title only uses a name when ALL runtimes
share one (sharedCustomName). A lone per-runtime name would thus pre-fill as
if it were the machine name — the same runtime-vs-machine confusion this PR
set out to remove. Export sharedCustomName and use it for the pre-fill;
otherwise pre-fill empty. Added direct unit tests.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213)
Scan-to-bind was authorized by workspace role only, so a non-admin
member could not bind a Lark bot even to an agent they own. Authorize
the device-flow install, status poll, and revoke by the same rule that
governs every other agent-management op — canManageAgent: the agent's
owner OR a workspace owner/admin.
Backend:
- router: begin/status/revoke drop to workspace-member level; the
per-agent check moves into the handlers (agent_id is a query param /
installation id, which the role middleware can't see).
- BeginLarkInstall + RevokeLarkInstallation load the target agent and
run canManageAgent.
- GetLarkInstallStatus scopes the read to the session initiator or a
workspace owner/admin; others get 404 (no existence leak). Session
state now carries InitiatorID for this.
Frontend:
- LarkAgentBindButton takes agentOwnerId and lets the agent owner
through (mirrors canEditAgent).
- Agent Integrations tab gates Lark per-agent (owner or admin) while
Slack stays workspace-admin-only, since its routes are unchanged.
Tests: begin/status/revoke authorization (owner, agent owner, unrelated
member) on the backend; agent-owner bind visibility on the frontend.
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): keep orphan installation revoke available to workspace admins (MUL-4213)
RevokeLarkInstallation loaded the bound agent and ran canManageAgent
unconditionally, so once the agent was hard-deleted the load 404'd and
a workspace owner/admin could no longer disconnect the orphan Lark
installation — a documented cleanup path (ListByWorkspace lists orphans;
the active-connection query filters them; Settings surfaces "Unknown
Agent" Disconnect).
Fall back to workspace owner/admin-only revoke when GetAgentInWorkspace
finds no agent; agents that still exist keep the owner-OR-admin
canManageAgent check. A plain member gains no orphan-row cleanup rights.
No FK/cascade — resolved in the application layer.
Adds a backend regression test: orphan installation is revocable by a
workspace owner but not a plain member.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(runtimes): custom runtime names + searchable machine-grouped picker
MUL-4217. Runtime names were daemon-generated ("Claude (host)") and
uneditable, so picking one at agent-create time was painful once a
workspace had many machines.
Phase 1 — create-agent RuntimePicker: add a search box (>6 runtimes) and
group options by machine (Local/Remote/Cloud, online-first, current
machine first) reusing buildRuntimeMachines/filterRuntimeMachines. Rows
show the provider under a machine header instead of a flat repeated list.
Phase 2 — custom names: new nullable agent_runtime.custom_name column,
never written by the registration/heartbeat upsert so the daemon can't
clobber it; display is coalesce(custom_name, name) via runtimeDisplayName.
PATCH /api/runtimes/:id gains custom_name (+ apply_to_machine to name every
runtime sharing a daemon_id in one action, owner-scoped for non-admins).
Rename UI on the runtime detail page; `multica runtime rename` CLI command.
Verified: go build/vet, sqlc, handler tests (incl. new custom-name single
+ machine-fanout), 1650 views + 764 core TS tests, typecheck, locale parity.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): address review — persist machine name on new registrations, keep custom_name in register response
Elon's review on #5070 (MUL-4217):
1. Machine name looked "lost" when a new provider registered on an
already-named machine — the new row landed with custom_name=null and
broke sharedCustomName. Now a fresh runtime inherits the machine's shared
custom name at register time (ListDaemonCustomNames + sharedDaemonCustomName),
so the machine title stays stable as providers come and go.
2. DaemonRegister rebuilt the response row by hand and dropped custom_name,
so register returned custom_name:null — inconsistent with list/get/update.
Both branches now carry CustomName.
Also: tighten the updateRuntime patch type to custom_name?: string (drop the
misleading `| null`, since the server treats null as "unchanged", not "clear").
Tests: register response preserves custom_name; new runtime inherits machine name.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): inherit machine name for failed-profile registrations too
Elon's re-review of #5070 (MUL-4217): the machine-name inheritance added
last round only covered the normal req.Runtimes path. The req.FailedProfiles
branch also upserts a daemon_id-scoped agent_runtime row (offline, profile
registration error), which shows up in the runtime list / machine grouping —
so on a named machine a failed custom-profile row landed with custom_name=NULL
and dragged the machine title back to the hostname.
Extract the inheritance into h.inheritMachineCustomName and call it from both
the normal runtime path and the failed-profile path. Add a test: named daemon
+ failed profile upsert -> the failed row's persisted custom_name is inherited.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Squad create/manage was gated behind workspace owner/admin, inconsistent with
agents and projects which any member can create. Move squads to a creator-scoped
model: any member can create a squad and becomes its creator, and manages only
the squads they created; owner/admin continue to manage every squad.
Backend (server/internal/handler/squad.go):
- Add canManageSquad (admin/owner OR creator) and gate UpdateSquad, DeleteSquad,
AddSquadMember, RemoveSquadMember, UpdateSquadMemberRole on it (member load +
squad load + per-squad check, replacing requireWorkspaceRole).
- CreateSquad is now member-creatable.
- Add memberCanWireAgent: a non-admin may only wire agents they can @-trigger
(canInvokeAgent as themselves) as squad leader (create/update) or worker
(add member); admins may wire any workspace agent. Prevents a creator from
smuggling an agent they cannot invoke into a squad.
Frontend:
- squad-detail-page: compute per-squad canManage (admin || creator) and render
the inspector, members tab, instructions and archive read-only otherwise,
mirroring the agent detail canEdit pattern.
- squads-page: per-row actions and the actions column now key off per-squad
canManage instead of workspace-admin.
Squads stay visible workspace-wide (ListSquads unfiltered); creator transfer is
out of scope for this iteration.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The coordinator's absent-card status-move branch shifted one unit of
server total between the two status buckets and stopped there — no
stale key. Under the app's staleTime: Infinity + no focus-refetch
setup, explicit invalidation is the only channel that reconciles a
loaded list, so an open board would show the moved count with the row
permanently missing from the destination bucket's visible window
(e.g. "done 61" with 60 visible rows) until an unrelated event
happened to invalidate the list.
Push the list key onto staleKeys after a successful moveBucketTotal.
The count still moves instantly (optimistic UX unchanged); the flush
timing follows the existing contract — mutations invalidate on
onSettled, the WS path immediately. No mutation/WS fork, no new
coordinator parameters (MUL-4182).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(workspace): drop workspace from list cache while delete is pending (MUL-4129)
The delete-workspace flow navigates away before awaiting the DELETE
(required ordering — see navigateAwayFromCurrentWorkspace's
CancelledError notes), but useDeleteWorkspace left the workspace in the
list cache until onSettled. During the pending window any list refetch
re-presented the deleting workspace as a selectable/current option.
Optimistically remove it in onMutate (after cancelling in-flight list
fetches), roll the snapshot back in onError so a failed delete restores
the workspace alongside the existing error toast, and keep the
onSettled invalidate as the server-truth reconcile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(workspace): own storage cleanup on delete success and tombstone pending deletes (MUL-4129)
Address final-review blockers on #4980:
1. The realtime workspace:deleted handler reverse-looks-up the slug from
the list cache to clear the ${key}:${slug} persisted namespace; the
optimistic removal empties that row on the initiating client, so the
lookup misses and cleanup was silently skipped. Capture the slug in
onMutate before removal and clear storage in onSuccess only — a
failed DELETE rolls back and must not touch persisted state.
2. cancelQueries only covered fetches already in flight at onMutate.
Add a pending-delete tombstone (marked onMutate, lifted onSettled
before the reconcile invalidate) filtered inside workspaceListOptions'
queryFn, so invalidation/reconnect/fetchQuery refetches that land
mid-pending cannot write the not-yet-committed row back into cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(claude-md): scope optimistic updates to same-screen field patches
Replace the blanket "mutations optimistic by default" state rule with
three scoped rules: optimistic only for predictable same-screen field
patches; navigating/confirming flows (create/delete/leave) await the
server first; chat send uses the pending-message pattern.
Aligned with TanStack Query maintainer guidance and React Router's
pending-UI criteria; the old blanket rule is what steered the original
MUL-4129 fix toward optimistic entity removal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(workspace): await delete before navigating; drop optimistic removal (MUL-4129)
Rework of the previous approach on this branch. The optimistic removal
emptied the workspace list cache at click time, while the settings page
was still mounted on the old slug — useWorkspaceId (URL slug + list
lookup) then threw 'no workspace selected'. Root cause of the original
navigate-first ordering was dual ownership of delete handling between
the initiating flow and the realtime workspace:deleted handler.
- useDeleteWorkspace: no optimistic removal, no rollback; onMutate only
marks the delete self-initiated and captures the slug; onSuccess owns
storage cleanup; onSettled invalidates.
- pending-delete.ts: repurposed from tombstone filter to self-initiated
marker; kept on success (suppresses the WS echo), lifted on failure.
- use-realtime-sync: workspace:deleted no-ops for self-initiated
deletes; it now only serves deletes initiated elsewhere.
- workspace-tab: confirm dialog stays open in loading state, navigate
only after the DELETE succeeds; failure leaves the user in place with
nothing to roll back. Replace throwing useWorkspaceId with
workspace?.id + enabled gating (independent crash on external
deletes of the current workspace).
Known debt: useLeaveWorkspace still navigates before awaiting
(member:removed has no self-initiated marker yet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159)
ListPendingChatTasksByCreator was a top DB hotspot. Root cause: the partial
index idx_agent_task_queue_chat_pending (migration 040) only covers
status IN (queued, dispatched, running), but migration 109 added a fourth
in-flight status (waiting_local_directory) that both pending chat queries now
filter on. Postgres can only use a partial index when the query predicate is a
subset of the index predicate, so the 4-status query stopped using it and
degraded to a Seq Scan over the whole agent_task_queue.
Implements the reviewed P0-P3 plan in one PR:
P0 index fix (split single-statement CONCURRENTLY migrations per repo convention)
- 143: CREATE INDEX CONCURRENTLY idx_agent_task_queue_chat_pending_v2 covering
all four in-flight statuses (same column list, so GetPendingChatTask still
benefits).
- 144: DROP the superseded 3-status index, in its own migration.
P1 SQL + handler hot path
- ListPendingChatTasksByCreator now returns cs.agent_id and states
chat_session_id IS NOT NULL so the planner can prove the partial-index subset.
- ListPendingChatTasks filters private-agent access against the already-loaded
accessible-agent set using the returned agent_id, dropping the extra
ListAllChatSessionsByCreator scan on the hot path.
- Regenerated sqlc.
P2 frontend request amplification
- FAB uses the new boolean has-any query gated on enabled:!isOpen, so the
minimised button never holds the full aggregate.
- use-realtime-sync maintains the pending aggregate (list + has-any) in place
from task lifecycle events (queued/dispatch/running/waiting_local_directory
-> upsert; completed/failed/cancelled -> remove) instead of invalidating on
every chat:message/chat:done, with a debounced fallback invalidate for
reconnect / unknown payloads.
P3 boolean endpoint
- GET /api/chat/pending-tasks/has-any backed by HasPendingChatTasksByCreator
(EXISTS). Permission filtering is baked in via agent_id = ANY($3); an empty
accessible-agent set short-circuits to false. The detailed list stays for the
ChatWindow history / stop-task flows.
Tests: new handler tests cover the private-agent gate on both endpoints
(hidden from a creator who lost access, visible to the agent owner) plus the
boolean status/terminal semantics.
EXPLAIN (ANALYZE, BUFFERS) on a 300k-row reproduction:
- before (3-status index): Parallel Seq Scan, ~300k rows filtered,
shared hit=3012, 12.1 ms.
- after (v2 index): Index Scan on idx_agent_task_queue_chat_pending_v2,
shared hit=131, 0.07 ms.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): stop optimistic cross-session pending aggregate writes from workspace-fanout task events (MUL-4159)
Review on PR #5018 flagged a real privilege-escalation bug in the P2 change:
use-realtime-sync optimistically upserted the cross-session pending aggregate
(pendingTasks / pendingTasksHasAny) from chat task:* events. Those events are a
workspace fanout delivered to every member (server still BroadcastToWorkspace,
see cmd/server/listeners.go), and the payload carries no creator / agent
visibility. So member B starting a chat task could flip member A's FAB to
has_pending=true, bypassing the server-side permission filter on
/api/chat/pending-tasks[/has-any].
Fix (option 1 from the review — the self-contained one): never optimistically
write the aggregate from task:* events. On every task lifecycle transition,
debounced-invalidate the aggregate so it is refetched through the
permission-filtering endpoint, which only returns the caller's own
creator-owned, accessible-agent tasks. The per-session pendingTask cache is
still written directly — it is keyed by chat_session_id and only rendered for
sessions the user can open (server-gated), so it is not a cross-user leak.
chat:message is still excluded from aggregate refresh, so the MUL-4159 request
storm stays fixed; task transitions are per-task and coalesced by the debounce.
- Removed upsertPendingAggregate / removePendingAggregate.
- Added exported refetchPendingChatAggregate(qc, wsId) — an invalidate, never a
setQueryData — used by the debounced handler.
- Regression tests: refetchPendingChatAggregate leaves the cached
has_pending/list untouched (no optimistic write) and only invalidates for an
authoritative server-filtered refetch; no-ops without a workspace id.
Verified: @multica/core + @multica/views typecheck; full core vitest suite
(752 tests) green including the 2 new guard tests.
Co-authored-by: multica-agent <github@multica.ai>
* chore(chat): address review nits on pending-tasks endpoints (MUL-4159)
- Restore the GetPendingChatTask godoc first line that was clipped when the
has-any handler was inserted (nit#1).
- ListPendingChatTasks short-circuits to an empty list when the caller has no
accessible agents, mirroring HasPendingChatTasks — skips the DB round-trip
(nit#2).
- Add a cross-creator negative test: user A's in-flight task on a
workspace-visible agent returns has_pending=false / empty list for user B,
locking the cs.creator_id tenant gate that the agent-visibility filter does
not cover (nit#3).
Verified: go build ./... and go test ./internal/handler -run PendingChatTasks
(7 tests) green against live Postgres.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>