* feat(issues): add sticky setting for the issue comment bar (MUL-4435)
The bottom comment composer can now pin itself to the scroll viewport so
it stays reachable while reading a long timeline. A pin toggle on the bar
itself persists the preference (default: on) via a new localStorage-backed
useCommentComposerStore. While pinned, the editor area is height-capped so
long drafts scroll internally instead of covering the timeline.
Co-authored-by: multica-agent <github@multica.ai>
* refactor(settings): move the sticky comment bar toggle to Settings > Preferences (MUL-4435)
Per review feedback: pinning the comment bar is a low-frequency choice, so
the toggle moves off the bar itself into Settings > Preferences as a Switch
row. The composer keeps reading the store for the sticky behavior and the
40vh editor cap; the pin button and its issues-locale tooltips are removed.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): add DM button on agent detail page
The header gains a DM action next to Assign work. It shares the MUL-3963
invocation gate with assignment: allowed users land on the Chat tab with a
fresh compose bound to the agent via a new one-shot ?agent=<id> deep link
(consumed once the permission-filtered agent list resolves, then stripped);
denied users get an explanatory toast instead of a hidden affordance.
Closes MUL-4432
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): harden the ?agent= deep link against races and undetermined permissions
Review follow-up for the DM button (PR #5289):
- An explicit user action (thread select, archive, manual new chat) now
supersedes a still-pending ?agent= intent, so an intent deferred by slow
agent/member queries can no longer fire late and clobber that choice.
- The composingNew reset reads the live store value; under StrictMode's
effect replay the render-captured snapshot re-closed the compose pane the
intent had just opened (one-shot guard rightly refuses to re-fire).
- A settled miss (revoked access, archived agent, bad id) now toasts and
strips the param instead of silently keeping a re-fireable intent; still-
loading queries keep the intent pending. Query errors never settle.
- useAgentPermissions exposes isLoading: the detail page disables DM while
membership resolves instead of toasting a false not_member deny at a
legitimate member.
The chat store test mock is now reactive (useSyncExternalStore) — with a
plain mutable ref React bails out of committing post-effect re-renders and
the StrictMode regression cannot reproduce. Both new regression tests fail
against the pre-fix code.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Replace per-row controlClassName widths with a size enum (text /
select-wide / select / code / none) on SettingsRow. Text-entry fields
(name, slug, description, context, bio) now share the text tier so
their control edges align within a card; deliberately-short fields
(issue prefix) keep a clearly smaller tier.
MUL-4428
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
SubmitButton is now always circular — the shape prop is removed since
every composer uses the same silhouette. ReplyInput drops its inline
icon-xs Button for the shared SubmitButton, gaining the same size,
disabled state, and send tooltip as the comment composer and chat.
MUL-4433
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): let workspace members view runtime capabilities (MUL-4427)
The Agent capabilities redesign (#5277) reused the runtime local-skills
discovery endpoint on Agent detail surfaces, but the endpoint kept the
owner-only gate from the original import flow. Viewing an agent bound to
someone else's runtime returned 403, which the Skills / MCP tabs rendered
as 'try again when the runtime is online' even though the runtime was
online.
- Discovery (list + poll) now requires workspace membership only; the
payload is the deliberately redacted inventory built for this display.
- Import (init + poll) stays owner-only: it copies skill file contents
off the owner's machine.
- The failed notice no longer blames runtime connectivity, and a 403
(new client against an older backend) gets an honest permission
message.
* test(settings): stub Intl.supportedValuesOf in timezone picker tests
The preferences-tab timezone tests drove a ~600-option Base UI Select
through userEvent in jsdom; on slow CI runners the clear-preference case
exceeded even its extended 20s per-test timeout (PR #5281 frontend job).
Stub the IANA enumeration down to the curated COMMON_TIMEZONES fallback
— everything the tests pick lives there too — and drop the now-unneeded
20s overrides. File test time drops from ~35s to under 1s.
---------
Co-authored-by: Lambda <lambda@multica.ai>
The agent detail page wrapped both the sidebar rail and the content pane
in one scroll container, so scrolling any Capabilities/Settings tab
dragged the Instructions/Skills/MCP rail out of view. Split scrolling on
md+ like settings-page.tsx: the outer container stops scrolling and each
pane scrolls itself. Below md the page still scrolls as one, and the
Overview/Work views keep whole-page scrolling.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* 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>