The issue and project start/due-date pickers were near-complete copies of the
same Popover + Calendar + clear wiring, so they could drift in behaviour or
formatting. Extract that into one entity-agnostic DateOnlyPicker
(packages/views/common/date-only-picker.tsx); each of the four pills is now a
thin wrapper supplying only its field name (via onChange), icon, overdue flag,
and localized copy. -264 lines of duplication, single source of truth.
Behaviour is unchanged: the issue pickers keep their full API (trigger /
triggerRender / open / onOpenChange / align / defaultOpen — all still used by
board-card, issue-detail, create-issue) and the calendar-day contract stays in
@multica/core/issues/date. The en-US display format now lives in one place
rather than being duplicated per entity.
Full views test suite (1857 tests) + typecheck + lint pass.
Part of #5227
Co-authored-by: multica-agent <github@multica.ai>
Restructure the create-project modal footer to match the create-issue pattern
(per design feedback): the primary action moves out of the cramped single
justify-between row into its own border-t action strip, and the property pills
sit in a dedicated wrapping toolbar above it. Low-frequency fields (start/due
date) collapse into a ⋯ overflow via progressive disclosure — a pill only
renders inline once its date is set or the user opens it from the menu — so the
default toolbar stays a clean single row (Status · Priority · Lead · Repos · ⋯).
- Use the shared PillButton (../common/pill-button) instead of the modal-local
copy, gaining the data-popup-open styling create-issue uses.
- ProjectStartDatePicker / ProjectDueDatePicker gain controlled open props so
the overflow menu can reveal + open them (mirrors the issue pickers).
- i18n: create_project.set_start_date / set_due_date / more_options_aria across
en / zh-Hans / ja / ko, reusing the create-issue wording.
Test updated to assert the dates are revealed from the overflow rather than
shown inline by default. typecheck / lint / i18n parity pass.
Part of #5227
Co-authored-by: multica-agent <github@multica.ai>
#5313 landed the backend start_date/due_date fields + types but deliberately
shipped no UI. Wire up the two editor surfaces users expect:
- ProjectStartDatePicker / ProjectDueDatePicker mirror the issue pickers (same
calendar-day contract, clear idiom, shared @multica/core/issues/date helpers)
but are typed to UpdateProjectRequest and scoped to the "projects" i18n
namespace. One component serves both surfaces via a custom trigger.
- Create-project modal: two date pills; values flow into the create payload and
the persisted draft (draft-store gains startDate/dueDate).
- Project sidebar (project-detail): two PropRows after Lead, wired to the update
mutation, with clear support (send null).
- i18n: prop_start_date / prop_due_date / clear_date across en/zh-Hans/ja/ko,
reusing the existing issue date wording.
Tests: picker display + clear behavior (real popover), and the create modal
renders both pills. typecheck + lint + i18n parity pass.
Part of #5227
Co-authored-by: multica-agent <github@multica.ai>
Projects become schedulable planning objects alongside their issues: add
optional start_date / due_date, mirroring issue.start_date / issue.due_date.
This is only the first slice of #5227 — labels, metadata, and the editable
metadata UI are still out of scope.
- migration 166: two nullable DATE columns on `project` (calendar days, no
FK/index — matches the issue end-state after migration 112)
- sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses
narg so an explicit null clears
- handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on
update, and the hand-scanned SearchProjects query returns the columns
- CLI: `project create/update --start-date/--due-date` (empty clears on update)
- frontend + mobile types/zod schemas: the two new schema fields are
nullable().default(null) so a project from an older backend (frontend
deploys before backend) parses to null instead of degrading the batch to
the empty fallback; added a search schema drift test
- projects skill / CLI docs
Part of #5227
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): search runtime local skills
* feat(skills): highlight matched substrings in runtime local skill search
Reuse the shared HighlightText (the same component the global search
command uses) to highlight matched substrings in a result's name,
provider, description, and path, so styling stays consistent across the
app. Narrow the search to the fields the row actually renders and drop
`key`, so every match maps to something visible. While a query is active,
lift the description's 2-line clamp so a match past the first two lines
stays on screen instead of being clipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Archiving a channel-bound chat session now severs its
channel_chat_session_binding in the same tx as the status flip. The web
send path already treats status='archived' as read-only, but the channel
engine (Feishu/Slack) resolves inbound traffic through the binding without
checking session status, so an archived-but-still-bound session kept
receiving agent replies and a stuck, uncleared unread badge. Dropping the
binding makes the next inbound message fork a fresh session; unarchive does
NOT recreate it (a later session may already own the channel).
The FAB unread badge counted status=all sessions without excluding
archived, so residual unread on an archived session (archive does not
mark-read) held the badge even though the session is hidden and read-only.
Extract countUnreadChatSessions() and exclude archived. This matches what
mobile already computes (active-only list), restoring count parity.
Tests: backend archive-clears-binding + unarchive-does-not-recreate;
frontend countUnreadChatSessions archived-exclusion cases.
MUL-4372
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): support images/files in agent chat replies (MUL-4287)
Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.
Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
the assistant message via BindChatAttachmentsToMessage (rejects rows already
owned by an issue/comment/chat_message). An empty-output reply that produced
files still creates a message so the images have an owner. FailTask binds
nothing.
CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
(task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
ready-to-paste markdown snippet.
Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.
Mobile:
- chat:done handler now always invalidates the messages list so attachments
(absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
CommentAttachmentList (dedup vs inline references), matching web.
Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.
Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address review blockers on chat reply attachments (MUL-4287)
Two final-review blockers on PR #5164:
1. Mobile inline dedup only checked raw `url`, so an attachment referenced
inline via `markdown_url` (exactly what the CLI snippet emits) rendered
twice — once inline, once as a standalone card. Reuse the core
`contentReferencesAttachment` helper so dedup covers every real reference
form (stable /api/attachments/<id>/download path, url, download_url,
markdown_url), matching web's AttachmentList. Extracted the filter into a
pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
regression test covering `content` containing `attachment.markdown_url`
(plus the other URL forms and same-identity sibling dedup).
2. CLI `attachment upload` emitted `![...]` image markdown for every file,
producing a broken-image snippet for non-images. Emit image markdown only
for image/* content types and a plain link otherwise, with a CLI contract
test for both.
Approved scope otherwise unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)
Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): render agent-produced files as attachment cards, not raw links
The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.
Return the block-level `!file[name](url)` card syntax instead (images keep
`` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): soften message-list scroll fade (32px → 16px)
The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): renumber attachment_task_id migration 157 -> 158
main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): pin attachment upload to the token's task; build index concurrently
Two code-review findings on the chat-attachment path (MUL-4287):
- Isolation/privacy: POST /api/upload-file only checked the form task_id
belonged to the caller's agent, not that it matched the task-scoped token's
authoritative X-Task-ID. A run authorized for task A could tag an attachment
onto task B (another chat task of the same agent, possibly another user's
session), binding it into that reply on completion. Require the form task_id
to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.
- Migration: split the task_id lookup index into its own migration (159) built
with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
multi-command file with the ADD COLUMN in 158.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)
Addresses the two remaining Preflight BLOCKERs on PR #5164.
Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.
Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)
Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.
- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
matched — the line stayed literal AND `standaloneAttachments` still hid the
fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
restricted to the same allowlist (site-relative /uploads + /api/attachments/
<UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
by the renderer — unlike web's HTML data-filename), so a raw `]` never
truncates the link text.
No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.
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(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>