mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
agent/lambda/768b92e0
170 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b9faa27a67 |
fix(editor): open mention/slash popup upward and clamp height to viewport (#5376)
The `@`-mention and `/`-command suggestion popups defaulted to `placement: "bottom-start"`, so they stayed below the caret whenever any space existed below it — even when far more room was above. In bottom-anchored composers (chat input, issue comment/reply) that meant the list opened down over the send controls and, near the viewport bottom, was squashed or clipped off-screen. Two compounding causes: - The preferred side was the cramped one. Composers' roomy side is above the caret, so default to `top-start`; `flip` still sends it down when the caret is near the viewport top. - The floating-ui `size` middleware wrote `maxHeight` on the outer wrapper, which does not clip — the inner list is the scroll container and carried its own fixed `max-h-[300px]/[420px]`. That viewport-unaware cap was the real height authority and could overflow. Publish the size middleware's `availableHeight` as a CSS var and have the list clamp to `min(designMax, availableHeight)`, so there is a single, viewport-aware height authority. Drop the old `Math.max(120, ...)` floor that forced overflow in tight bands. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b64ebd60b5 | feat: add customizable keyboard shortcuts (#5294) | ||
|
|
932bbf2bb5 |
fix(editor): guard Mod-Enter submit against open IME composition (#5231)
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> |
||
|
|
f4de0948a2 |
refactor(ui): unify ActorAvatar size tiers + round all avatars & cropper (MUL-4277, MUL-4184) (#5133)
* 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> |
||
|
|
3f02083fec |
feat(editor): Linear-style issue identifier autolink (MUL-4241) (#5090)
* 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> |
||
|
|
4763772c4f |
feat(chat): auto-focus compose box on new chat (#5146)
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> |
||
|
|
0ffb5f6863 |
fix(markdown): drop trailing markdown delimiters from linkified URLs (MUL-4242) (#5139)
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> |
||
|
|
cac2965ddb |
fix(markdown): autolink read-only URLs in the parse tree, not raw text (MUL-4242) (#5091)
* 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> |
||
|
|
956f74e1d2 |
fix(chat): refresh Chat V2 input placeholder without remount (MUL-4276) (#5111)
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> |
||
|
|
a51ab4d551 |
feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) (#5076)
* 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> |
||
|
|
ebd0248be2 |
MUL-4016: fix mention tokenizer stacktrace backtracking (#4889)
* MUL-4016: fix mention tokenizer stacktrace backtracking Co-authored-by: multica-agent <github@multica.ai> * fix(editor): de-ambiguate escaped-label regexes to kill ReDoS (MUL-4016) The mention/slash/file-card label regexes used `(?:\\.|[^\]])` where both alternatives can consume a backslash. On an unterminated match, each `\x` run is enumerated 2^n ways — pasting a Java stacktrace (`\~\[...\]`) or a crafted ~50-char string freezes the main thread for seconds (GitHub #4881). Exclude backslash from the char class (`[^\]\\]`) so a backslash can only be consumed by `\\.`. The alternatives become disjoint and matching is linear; legal escaped-bracket labels like `David\[TF\]` still parse unchanged. Fixed in all four sites that shared the pattern: - mention-extension.ts tokenize() - slash-command-extension.ts start() + tokenize() - file-card.tsx FILE_CARD_MARKDOWN_RE - packages/ui/markdown/file-cards.ts NEW_FILE_CARD_RE (runs on every read-only comment/description render, not just the editor) Adds adversarial regression tests (repeated `\a` + missing closing bracket) that fail in ~10-40s against the old regexes and pass in <1ms after the fix. Builds on #4889's marker-first mention start(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(editor): escape backslash in mention/slash labels for round-trip (MUL-4016) Follow-up to the de-ambiguation fix, addressing Howard's PR review. The linear tokenizer now treats "\" as an escape lead (\\.), so a label whose serialized form contains a bare "\" adjacent to the closing "]" no longer parses back — the "\]" is consumed as an escaped bracket and swallows the boundary. The old ambiguous regex tolerated this by chance; the de-ambiguation exposes it. mention/slash renderMarkdown escaped only [ and ], not \. Switch both to the shared escapeMarkdownLabel() (escapes [ ] \ ( )) and mirror it on parse with replace(/\\([[\]\\()])/g, "$1"), matching what file-card already does. This also converges the three tokenizers on one escape contract. file-card was already correct and is unchanged. Adds parameterized round-trip tests for labels containing "\" / "\]" / parens (e.g. "A\\", "ends\\", "a\\]b"); these fail on the old serializer and pass now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f51b4f62f |
fix(editor): repair empty list items parsed from a markdown draft (#4869)
The real ordered-list caret bug (MUL-3973). Typing 1. in the comment box persists the draft "1. \n\n"; on remount @tiptap/markdown parses that empty item into a schema-invalid, childless listItem, leaving the document with an AllSelection instead of a text cursor — so the browser paints the caret on the following block and it can't be moved back into the list. Verified against the REAL editor (real createEditorExtensions + @tiptap/markdown, no @tiptap/react mock). Non-empty items round-trip fine; only the empty-item round-trip corrupts, which the reverted #4813 never exercised. - repairEmptyListItems(): rebuild from JSON so every list/task item leads with a paragraph (covers empty items and nested items whose first child is a sub-list); reset the AllSelection first (else setContent collapses the list); keep the whole repair off the undo stack; restore the prior caret (sync path) or land in the list item (mount). - Called in onCreate (mount) and after the sync-effect setContent. - Real-editor tests incl. undo-does-not-revive and nested-list schema validity. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
cb68669c73 |
feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)
Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).
- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
CompleteCallback (idempotent upsert), ListConnections, Disconnect
(upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
handlers (httptest), redact regression for Bearer mcp_ tokens
MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)
Address PR 4608 review (CHANGES_REQUESTED):
- callback: verify connected_account_id with Composio before mirroring it.
The signed state only proved user/toolkit/exp, so a valid state paired with
a tampered connected_account_id would be written verbatim. CompleteCallback
now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
unless the account belongs to the state's user (composio_user_id == multica
user id) and was created under the toolkit's auth config. No row is written
on mismatch / unknown account / upstream error.
- disconnect: short-circuit to a no-op when the local row is already revoked,
before touching upstream. Previously a second DELETE re-hit Composio and a
non-404 upstream error surfaced as a 502, breaking the 204-idempotent
contract.
- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
constraint and make duplicate selection deterministic (newest-wins, rows are
connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
real single-account-enforcement vs multi-account-shape decision.
Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)
Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.
Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
is now resolved at request time from the project's /auth_configs (cached,
5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
to exactly one project; the project is resolved from the key. Only org-level
endpoints use x-org-api-key, which this integration never calls.
Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
custom/white-label config over Composio-managed, newest wins); BeginConnect
and CompleteCallback resolve via it; ListToolkits fetches the full catalog
(paginated, capped) annotated with connectable = has an enabled auth config,
connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.
Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
toolkit as a card with search. Connect is gated on `connectable`;
non-connectable toolkits show a muted "not configured" hint instead of a
dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.
Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)
Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.
Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.
Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.
Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)
Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.
Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)
* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)
* fix(composio): move OAuth callback out of the Auth group (MUL-3843)
Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.
Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.
Refs MUL-3843, MUL-3715.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): correct stale callback routing comments (MUL-3843)
The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.
Refs MUL-3843.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)
Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.
Server side
- Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
BEFORE-UPDATE trigger that wipes the column on any transition into a
terminal status (completed / failed / cancelled). A trigger is the single
source of truth — future queries that flip status cannot bypass it.
- composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
emits the Claude-style { mcpServers: { composio: { type: http, url,
headers } } } shape the daemon's existing sidecar generators consume.
Returns (nil, nil) on zero active connections so we never burn a
Composio session for a user with nothing to call.
- TaskService grows a Composio ComposioOverlayBuilder seam, wired in
router.go after composiointeg.NewService succeeds. Five enqueue paths
(issue / mention / quick-create / chat / auto-retry) attach the overlay
after CreateAgentTask returns and before the daemon is notified — so
every claim reads a settled row, with no second daemon hop. Best-effort:
a builder failure logs and proceeds with no overlay.
- resolveInitiatorFromTriggerComment derives the initiator user from the
trigger comment when it was authored by a member. Agent-authored
triggers are not treated as initiators (their connected-apps view is
empty by construction).
Daemon side
- handler/daemon.go claim path merges task.runtime_mcp_overlay onto
agent.mcp_config via mergeMCPOverlay before populating
TaskAgentData.McpConfig. Overlay wins on server-name collisions
because it carries the live user-scoped session URL. Errors fall back
to the agent config unchanged — a bad overlay must not surprise-disable
saved MCP tools. The existing execenv sidecar generators (cursor /
codex / openclaw / opencode / hermes-kiro) need no changes: they keep
consuming the merged result through TaskAgentData.McpConfig.
Tests
- 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
pass-through, overlay-only canonicalization, two-side merge, name
collision (overlay wins), top-level key preservation, malformed agent
fallback, malformed overlay fallback, non-object server rejection.
- 4 dispatch cases (composio): zero-connections returns nil without
CreateSession, happy-path emits the right shape with the right user
id, empty-URL defensive branch, SDK error surfacing.
- 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
invalid initiator does not call the builder, nil overlay skips the
UPDATE, builder error swallowed without panic.
- Migration 128 verified to roll up + down + up cleanly against the test
database.
Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)
Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:
- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
Owner-only read/write; non-owner GET/PUT silently redacts/drops the
field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
top-of-chain HUMAN at every enqueue path:
* issue/mention comment by member -> author_id
* issue/mention comment by agent -> inherit via comment.source_task_id
-> parent task originator_user_id
* quick-create -> requester_id
* chat -> initiator_user_id
* retry -> SQL-inherited from parent row
* autopilot -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
agent) and short-circuits on five gates: invalid originator,
originator != agent.owner_id, empty allowlist, empty intersection of
allowlist ∩ active connections, defensive empty session URL. Composio
CreateSession is called with BOTH `toolkits.slugs` (the intersection)
AND `connected_accounts` (the pinned account ids), narrowing the
tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
workspace member who can @-mention a public agent used to project the
owner's connected apps into their run. Now the overlay only mounts
when the human at the top of the chain IS the agent owner.
Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
(list/empty/null), workspace-admin silent-drop, owner-only GET
visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
agent-authored inherits via comment.source_task_id, invalid id.
Migration 129 up/down/up verified against docker postgres.
Co-authored-by: multica-agent <github@multica.ai>
* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): accept nested connected account auth config
* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)
Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869,
|
||
|
|
2cf5297814 |
Revert "fix(editor): keep the comment-box caret in the ordered list after switching issues (MUL-3973) (#4813)" (#4851)
This reverts commit
|
||
|
|
c2903365a9 |
fix(editor): keep the comment-box caret in the ordered list after switching issues (MUL-3973) (#4813)
* 📝 [需求] 有序列表光标切换缺陷提案 / docs: requirements for 26-editor-ordered-list-cursor Co-authored-by: multica-agent <github@multica.ai> * fix(editor): keep the caret in the list item when the comment box remounts Typing `1.` makes an ordered list, then switching issues and back yanked the caret to the next line and re-yanked it there whenever the user moved it. The comment box remounts on issue switch (key={id}) and feeds the persisted draft back as defaultValue; the content-sync effect treated the redrawn draft as an external change because @tiptap/markdown reserializes an ordered list slightly differently from its source, re-parsed it, and restored the caret with a bare Math.min offset clamp that resolved onto the list's structural (non-text) gap. - Short-circuit the sync effect when the incoming parser INPUT matches what was last applied (seeded at mount), so an identical draft is never re-parsed. - Replace the Math.min clamp with clampSelectionToText, which snaps the prior offsets to the nearest valid text position via TextSelection.between. Tests: restore-selection unit tests (real editor) + a content-editor remount regression test. Co-authored-by: multica-agent <github@multica.ai> * fix(editor): seed the caret guard synchronously and keep it in sync with the doc Addresses cross-review findings on the content-sync guard: - Seed appliedIncomingRef at render instead of in onCreate. Tiptap v3's onCreate is deferred past the first sync-effect run, so the guard was null on that run and a remount could still re-parse the ordered-list draft (the mock's synchronous onCreate had hidden this). - Advance appliedIncomingRef on the Guard 3 short-circuit too, so a later external change back to an older value (e.g. a collaborator reverts the description) is no longer mistaken for an already-applied input and swallowed. Tests: add a suppressed-onCreate remount case and an A->B->A external-revert regression; 23 passed. Co-authored-by: multica-agent <github@multica.ai> * docs: remove internal requirements proposal from the PR Keep the PR to code and comments only, per reviewer request. The requirement context lives in the tracking issue, not in this open-source code change. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
ac75a0df72 |
fix(attachments): align text preview whitelist (#4834)
Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
c3a33fff49 |
fix(markdown): render inline data-URI images (MUL-3961) (#4832)
* fix(markdown): render inline data-URI images (MUL-3961) Inline data:image/* URIs (QR codes, charts, base64 screenshots) were stripped and rendered as broken images. Two gates dropped the src: - rehype-sanitize's protocols.src only allowed http/https - react-markdown's defaultUrlTransform blanks any data: URL to '' Allow data:image/* through both gates, narrowed to image subtypes only (non-image data URIs stay rejected) and leaving every other src form unchanged. file-cards.ts data: rejection is intentional and untouched. Co-authored-by: multica-agent <github@multica.ai> * fix(markdown): allow data:image/* in ReadonlyContent too (MUL-3961) Issue comments and other read-only surfaces render through ReadonlyContent, which keeps its own sanitize schema + urlTransform separate from the base Markdown component. Both still stripped data: URIs, so an agent inlining an auth QR code in an issue comment still saw a broken image. Apply the same image/*-narrowed data: allowance (protocols.src + attributes.img + urlTransform) and add a regression test covering the comment / readonly path. file-cards.ts data: rejection stays untouched. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent-j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
5ed381a9d6 |
Fix comment attachment URL resolution (#4816)
Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8b9caded75 |
Fix Mermaid syntax error rendering (MUL-3900) (#4771)
* fix(views): preflight mermaid syntax before rendering * refactor(views): use suppressErrorRendering instead of a pre-render parse Enable Mermaid's suppressErrorRendering so render() throws on invalid syntax without drawing its built-in error graphic into the DOM, letting the existing catch fall back to the compact error state. This drops the redundant mermaid.parse() pass over valid charts while still fixing the orphaned error-SVG artifact from #3653. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
21d82b2ae5 | fix(editor): upgrade tiptap inline code handling (#4790) | ||
|
|
658e63d9be |
fix: prefer local upload attachment URLs (#4686)
Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
714f9b1ab7 |
fix(editor): keep Tab inside lists instead of escaping focus (MUL-3697) (#4605)
In a list item that cannot indent (first child / max depth), Tab was returned unhandled, so the browser's native Tab moved focus out of the editor onto adjacent controls. Decouple "swallow the key" from "did the indent move anything": best-effort indent, then swallow whenever the caret is inside the list (editor.isActive(name)) and only fall through to focus navigation when not in a list. Covers bullet, ordered and task lists via the shared keymap. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
553419f8ef |
fix(editor): indent multi-item list selection on Tab (MUL-3697) (#4587)
Stock prosemirror-schema-list `sinkListItem` returns false without dispatching whenever `range.startIndex === 0`, so selecting a list from the top and pressing Tab did nothing. Bullet, ordered, and task lists all routed through the same command and were equally affected. Wrap the shared Tab keymap (PatchedListItem + PatchedTaskItem) with `sinkListItemRange`: try stock sink first, and when it bails on a multi-item range whose first item is the list's first child, re-run the stock command on a selection narrowed to start inside the second selected item. The first item stays as an anchor and the rest nest under it (Notion/GitHub nested-list behaviour), in a single undoable transaction. Shift-Tab / liftListItem already handles ranges and the first-item case, so it is unchanged. No schema change. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
cd6cd9dcd1 |
fix(editor): keep code-block selection stable during background re-renders (MUL-3621) (#4594)
Selecting text in a readonly code block (comment/issue markdown) lost the
selection within seconds, making copy impossible, whenever the surrounding
view re-rendered — most reliably while a sibling agent task streamed over
WebSocket (a re-render roughly every ~100ms).
Root cause: the `code` renderer emits highlighted HTML via
`dangerouslySetInnerHTML={{ __html }}`, a fresh prop object every render. Each
unrelated parent re-render re-ran react-markdown, and React rewrote the
`<code>` innerHTML even though the HTML string was byte-identical, tearing down
and rebuilding all 161 hljs `<span>` nodes. The native selection is anchored to
those nodes, so it collapsed.
Fix: memoize the entire `<ReactMarkdown>` subtree on its only real inputs
(`processed` + `components`). A stable element reference lets React bail out of
the subtree on unrelated re-renders, so the code-block DOM is never rebuilt
while content is unchanged. Confirmed via an instrumentation probe: zero
`<code>` DOM mutations during streaming after the fix.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8ff312dbe9 |
feat(editor): accept highlighted composer suggestion on Tab (MUL-3685) (#4570)
* feat(editor): accept highlighted composer suggestion on Tab Plain Tab now accepts the highlighted mention / slash-command suggestion, matching Enter, across every composer built on the shared TipTap editor (chat, issue description, comment/reply). A single shared isPickerAcceptKey predicate centralizes the accept-key policy so the two picker lists stay in sync instead of each re-deciding what counts as accept. Shift+Tab and Ctrl/Cmd/Alt+Tab are intentionally NOT accept keys, so reverse focus navigation and OS window switching are preserved. When no picker is open, Tab keeps its existing behavior (list indent / focus traversal). Adds unit coverage for both picker lists plus a plugin-order guard that fires Tab through real ProseMirror dispatch with the caret inside a list item, proving the suggestion layer outranks PatchedListItem's Tab -> sinkListItem. Scope: web/desktop shared composer only; mobile and the generic combobox are untouched. Refs: MUL-3685 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * test(editor): make Tab/list-item priority guard actually exercise sinkListItem The guard placed the caret in the first (only) bullet item, where sinkListItem is a no-op (no preceding sibling to nest under), so the test passed regardless of whether the suggestion layer intercepted Tab. Rebuild the fixture as a two-item list with the caret in the SECOND item, where Tab -> sinkListItem can fire, and add a sanity control proving a bare Tab (no picker) does sink that item — so the accept-wins assertion is meaningful. Production code unchanged. Refs: MUL-3685 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> |
||
|
|
343ace89a7 |
feat(editor): MUL-3557 add one-click task-list toggle to bubble menu (#4538)
Add a dedicated checkbox/task-list toggle button to the editor bubble menu, between the List dropdown and Quote. It turns the current line(s) into a `- [ ]` task item or back to a paragraph in one tap, reusing the existing toggleTaskList() command and the same Toggle/Tooltip pattern as the neighboring controls. Adds bubble_menu.task_list locale keys for en/zh-Hans/ja/ko. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8a0934c741 |
fix(editor): track @mention selection by identity, not slot index (MUL-3607) (#4488)
The @mention popup tracked the highlighted row with a positional integer (selectedIndex into displayItems), but rows are rendered in the re-bucketed order produced by groupItems() (current → recent → search → users → issues). An async server "search" result is appended to the END of displayItems yet hoisted near the TOP on render, so the highlighted row and the committed item pointed at different entries — you navigate to one target but mention its neighbour. A separate useEffect also force-reset selectedIndex to 0 on every displayItems change, snapping an active selection back to the first row whenever async results landed. Root cause: a slot index is not a stable target for a list whose order and length change asynchronously. Track selection by item identity instead: - Replace selectedIndex state with selectedKey (the item's `type:id`). - Derive groups/orderedItems (the exact rendered order) and resolve the numeric index from selectedKey against orderedItems; fall back to row 0 when the pinned item is gone or nothing is picked yet. - Keyboard nav, Enter, clicks, highlight, and scroll all index orderedItems, so the highlighted row always equals the committed item. - Drop the force-reset effect; identity-based selection self-heals across reorders and async arrival without resetting an active pick. Adds a regression test asserting the highlighted row equals the committed item when groupItems reorders the list, both initially and after ArrowDown. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d43840f322 |
Revert "fix(editor): index @mention selection by rendered order (MUL-3607) (#…" (#4489)
This reverts commit
|
||
|
|
1890a9fa19 |
fix(editor): index @mention selection by rendered order (MUL-3607) (#4487)
The @mention popup navigated and committed by indexing the flat `displayItems` array, but rendered rows in the re-bucketed order from `groupItems()` (current → recent → search → users → issues). In chat (context mode) the async server-search results are appended to the end of `displayItems` yet tagged `group:"search"`, so `groupItems()` hoists them near the top. The highlighted row and the committed item then point at different entries — you select one target but mention its neighbour (the reported "@bohan picks the next one" off-by-one). Make the flattened grouped order (`orderedItems`) the single index space for `selectedIndex`, arrow keys, Enter, and clicks, so the highlighted row is always the committed item. Plain issue-comment mentions (default mode) were already safe — no group tags means `groupItems()` is order-preserving — and stay unchanged. Adds a regression test asserting highlighted row == committed item when the list is reordered by a hoisted search result. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
a5636f0ff4 |
feat: add copy button to readonly code blocks (#4448)
Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e4b53eb5c0 |
fix(editor): stop chat @mention groups from overlapping (#4297)
In context mode (chat) a query merges context items (current page / recently viewed) with search results into one popup. The old contextLayout made only the "Recent" group scrollable (`min-h-0`) while every other group was `shrink-0`, and the Recent `<section>` did not clip its own overflow. When the search groups (Users/Issues) filled the height, the flex algorithm squeezed Recent toward zero and its un-clipped rows painted on top of the groups below — the overlap users saw. Collapse the two render branches into a single `overflow-y-auto` flex column so every group just stacks and the whole popup scrolls; no group can collapse onto another. The context variant only differs in width / max-height / chrome. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4c7fefd143 |
fix(editor): cap issue mention chip at container width, no clickable gap (#4295)
#4288 swapped the chip cap from a fixed `max-w-72` to `max-w-[min(18rem,100%)]`. A percentage max-width on a flex item is dropped while its flex-container wrapper (`<a class="inline-flex">`) computes its own max-content size, so the wrapper ballooned to the untruncated title width while the chip truncated to the cap — leaving an empty, clickable strip after the visible chip. Fix it as standard atomic-inline behavior instead of a fixed magic cap: - IssueChip caps at `max-w-full` with the title truncating to an ellipsis, so it wraps to the next line as a unit and only truncates once a whole line can't hold it. - Drop `inline-flex` from the editor NodeView `<a>` (issue + project) and the markdown AppLink so the chip's only wrapper is a plain inline box with a definite (line-based) percentage basis — no second flex container to balloon. ProjectChip uses a definite `max-w-72`, so it never hit the gap; left as-is. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e9e97ee6d2 |
refactor(editor): dedupe upload-node scan and markdown normalization (#4267)
Extract three module-local helpers in content-editor.tsx and route the duplicated call sites through them — no behavior change: - normalizeMarkdown(md) / normalizeEditorMarkdown(editor): the single definition of the "strip blob URLs + trimEnd" canonical form, replacing the five editor-markdown sites and the one incoming-string site. - hasUploadingNode(editor): replaces the two byte-identical document scans (content-sync Guard 0 and hasActiveUploads). The imperative getMarkdown() is deliberately left untrimmed; a safety-net test pins its exact current return value. Scope: WOR-59 upload-readiness review. file-upload.ts untouched; follow-up items B1/F4/F5 are out of scope. Co-authored-by: hzz <331380069@qq.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
0f36c88855 |
fix(markdown): don't auto-link bare filenames as external URLs (#4245)
* fix(markdown): don't auto-link bare filenames as external URLs Agent comments that mention a project file like `plan.md` were turned into clickable links to https://plan.md (dead external site). linkify-it fuzzy detection matches `plan.md` as a domain because its extension is also a valid TLD (md = Moldova; likewise io, sh, rs, py). Suppress schemeless (fuzzy) linkify matches whose token is a bare filename (single segment ending in a known source/config extension). Explicit schemes (`https://plan.md`) and real domains (`example.com`) are unaffected. The file extension list is now shared between the file-path and bare-filename detectors so they can't drift. Fixes #4222 Co-authored-by: multica-agent <github@multica.ai> * docs(markdown): drop inaccurate .io example from bare-filename comment io is not in the FILE_EXTENSIONS list, so .io domains are never suppressed. Listing it as an example was misleading. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
f46b929ebc |
fix(editor): don't wipe in-flight uploads on external content sync (#4196)
* fix(editor): don't wipe in-flight uploads on external content sync When a brand-new chat's first file upload triggers lazy session creation, `setActiveSession(null → uuid)` flips ChatInput's draft key mid-upload, which changes `defaultValue` to the new (empty) session draft. ContentEditor's "sync external defaultValue" effect then ran `setContent` over a document that still held the `uploading` image/fileCard node, wiping it — so the upload's finalize could no longer find the node. The file vanished and the draft was left with an empty `!file[name]()`. The editor was never remounted (instance stays alive); the node was removed by the content-sync effect. An uploading node is local state an external sync must not overwrite, exactly like the existing dirty/focused guards. Add a guard that bails the sync while any `uploading` node is present. Pure frontend; affects only the first upload in a new chat (subsequent uploads hit an existing session, so no draft-key flip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(editor): cover the in-flight-upload content-sync guard The content-sync effect now reads `editor.state.doc.descendants` on every run to detect uploading nodes; the mocked editor didn't implement it, crashing all ContentEditor tests. Add `descendants` (driven by `editorState.uploadingNodes`) to the mock and a regression test asserting an external `defaultValue` change does not setContent while an upload is in flight, and resumes once it settles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): migrate new-chat draft onto the session id on lazy create The first file upload in a brand-new chat lazily creates the session, flipping ChatInput's draft key from `__new__:agent` to the session id mid-upload. The in-progress (empty-href) file-card markdown the editor had already written into the `__new__:agent` draft was neither migrated nor cleared, so it stayed stranded under that key — and resurfaced as a stale `!file[name]()` the next time a new chat opened for the same agent (the send only cleared the session-keyed draft). Migrate the `__new__:agent` draft onto the new session id the moment the session is created (upload path only — text send already clears the pre-flip key via `keyAtSend`). Add a shared `newSessionDraftKey` helper so ChatInput and ensureSession agree on the slot name, and a `migrateInputDraft` store action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
76c687d39a |
fix(markdown): allow attachment download file-card hrefs (#4145)
Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
2ab7b5b7af |
MUL-3280: fix(editor): repair split email links caused by autolink + inclusive:false
Fixes #4091 |
||
|
|
9a7eebb194 |
fix: re-sign unresolved attachment media urls (#4132)
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
6c17771cce |
fix: re-sign inline attachment media for token-mode clients (#4085)
The two prior MUL-3254 fixes preserved draft/description state across a modal close, but Desktop still could not RENDER the reopened image: in CloudFront signed-URL mode every URL the renderer holds after reopen is unloadable. The persisted record strips the expired signed download_url, the raw CDN url is unsigned (403 on a signed distribution), and the durable /api/attachments/<id>/download endpoint needs credentials that a cross-site file:// <img> fetch cannot carry (web works via the same-site session cookie, which is why the bug was desktop-only). Two changes close the last mile: - /api/config now reports cdn_signed when CloudFront signing is enabled, and pickInlineMediaURL stops picking the raw (unsigned) CDN url in that mode — it is a guaranteed 403. - The Attachment renderer upgrades an auth-gated media URL to a freshly signed one via authenticated GET /api/attachments/<id> (the same re-sign the click-time download path already does), but only on clients without a same-origin /api proxy (api.getBaseUrl() non-empty: Desktop, mobile webview). Cached via TanStack Query with a 20-minute staleTime, inside the server's 30-minute signed-URL TTL. Old servers omit cdn_signed; the schema defaults it to false so behavior is unchanged there. Non-CloudFront deployments return the API path again from the metadata fetch and the renderer keeps the original URL. Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
04a0677704 |
fix(markdown): keep dollar amounts literal in editor (#4084)
Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
ef08d8584c |
MUL-3254: flush issue description edits on close (#4082)
* fix: flush issue description editor on close Co-authored-by: multica-agent <github@multica.ai> * fix: make unmount flush opt-in via flushPendingOnUnmount The unconditional unmount flush re-emitted discarded content into composers that clear their draft and then unmount (comment edit cancel, create-issue / feedback submit), resurrecting the cleared draft. - Add flushPendingOnUnmount prop (default false); only the issue-detail description editor opts in. - Cache the pending markdown in a ref at onUpdate time and emit that cached copy on unmount, instead of reading the editor instance during teardown. - Regression tests: default drops the pending update on unmount, opt-in flush emits the cached value even when the editor is already destroyed, no double-emit after the debounce fired, and issue-detail pins the opt-in wiring. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
70b90d287c |
MUL-3267: fix(markdown): disable single-dollar inline math in web renderer
remark-math defaults to singleDollarTextMath: true, so any paragraph containing two dollar amounts (e.g. "costs $120/mo (~$85 net)") has the text between them parsed as inline TeX and rendered by KaTeX in an italic math font, with ~ treated as a non-breaking space. Disable single-dollar parsing in both web render paths, matching GitHub's behavior; explicit $$...$$ math still renders. Co-authored-by: Matt Voska <voska@users.noreply.github.com> |
||
|
|
fa15041864 |
MUL-3254: fix pasted image draft rendering in desktop (#4066)
* fix: keep issue draft attachment records Co-authored-by: multica-agent <github@multica.ai> * fix: avoid persisting signed draft attachment urls Co-authored-by: multica-agent <github@multica.ai> * fix: reuse resolved media url for draft previews Co-authored-by: multica-agent <github@multica.ai> * fix: address draft attachment review nits - Backfill an empty caller download_url from the in-session upload on id collision so a just-pasted image first-paints from the signed URL instead of detouring through markdown_url. - Prune draft attachments no longer referenced by the persisted description when the create dialog reopens. - Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts persisted before the attachments field existed get a stable shape. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
2754b7d7d8 | fix(attachments): render description images with CDN URL (#4005) | ||
|
|
f2ba3c8f1a |
fix(editor): wrap tables in tableWrapper so wide tables scroll locally (#4003)
Table.configure had renderWrapper unset (defaults to false), so tables rendered as bare <table> elements with no .tableWrapper div. The overflow-x: auto rule in prose.css targets .tableWrapper and never matched, so a wide table pushed the horizontal scrollbar onto the issue detail's page-level scroll container instead of scrolling within the table itself. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
abf99eb700 |
fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.
Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.
Server:
- AttachmentResponse gains a markdown_url field, computed by
buildMarkdownURL from the deployment policy:
• storage URL is already absolute + unsigned (public CDN, S3 public
bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
use it verbatim;
• CloudFront-signed mode → never expose the raw S3 URL (private
bucket); return cfg.PublicURL + /api/attachments/<id>/download so
the server can re-sign on every request;
• LocalStorage relative + cfg.PublicURL set → same prefixed API
endpoint;
• cfg.PublicURL unset → fall back to site-relative path so web's
Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
signature query params, so a freshly-signed download_url can never
leak into persistence — the original MUL-3130 bug stays closed.
Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
carry markdown_url. Schema lenient-defaults to '' so a backend old
enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
(1) att.markdown_url (modern server),
(2) attachmentDownloadPath(att.id) — legacy site-relative shape,
retained for backends old enough to omit markdown_url,
(3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
reframed as the legacy-compat fallback for already-persisted
/api/attachments/<id>/download or /uploads/<key> URLs in old
bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
AttachmentDownloadProvider so Quick Create's editor can swap the URL
via the resolver during the same session even before the server-side
binding lands.
Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
tests (public CDN passthrough, CloudFront-signed swap, relative
prefixing, PublicURL unset fallback, trailing-slash strip) + 15
table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.
Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.
Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.
Refs: MUL-3192
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
13e9485a3b |
MUL-3130: persist stable /api/attachments/<id>/download URL in comment markdown (#3937)
* MUL-3130: persist a stable attachment download URL in comment markdown Comment image attachments rendered as broken placeholders ~30 minutes after upload because the editor was persisting a short-lived HMAC-signed URL into the comment body. After PR #3903 (MUL-3132) hardened /uploads/* with auth, `attachmentToResponse` started signing `attachment.url` as `/uploads/<key>?exp=<unix>&sig=<HMAC>` for LocalStorage so token-auth clients could keep loading inline images. The signature has a 30-min TTL by design — but `useFileUpload` was returning that signed value as `link` and the editor was writing `` straight into the markdown, so the comment permanently captured a URL that stopped working as soon as the signature expired. The fix is to persist a stable per-attachment URL that the server can re-sign on every request: * `useFileUpload` now returns `link = /api/attachments/<id>/download` (avatar uploads without an id still fall back to `att.url` so the pre-attachment-row code paths keep working). * `DownloadAttachment` self-resolves the workspace from the attachment row instead of reading X-Workspace-Slug / X-Workspace-ID headers, and the route is registered under the auth-only group so a native browser <img>/<video> resource load (which cannot attach those headers) succeeds. Membership is checked inside the handler with a 404 deny shape so the route does not act as an IDOR oracle. * A new `GetAttachmentByIDOnly` SQL query supports the workspace- derivation step. * `AttachmentDownloadProvider` now extracts the attachment id from the stable URL when matching markdown refs to attachment records, with a fallback to the existing url-equality check for legacy comments (and S3/CloudFront markdown that points straight at the CDN). * `contentReferencesAttachment` covers both URL shapes for the composer / standalone-list dedup paths so an attachment uploaded before the fix and one uploaded after both deduplicate cleanly. Tests: - New unit tests for the URL helpers (16 tests, packages/core). - Backend regression test: bare `<img src>`-style request without workspace headers now succeeds for a member (200) and 404s for a non-member, replacing the previous "400 without workspace context" contract. - Existing TestDownload*, TestServeLocalUpload*, TestAttachmentTo Response* and the 1220 frontend views tests all pass. Refs: MUL-3130, GitHub issue #3891 Co-authored-by: multica-agent <github@multica.ai> * MUL-3130: address PR review — split markdown link from upload link, swap render src Two follow-ups from GPT-Boy's review on PR #3937. (1) Don't reroute every upload consumer through the workspace-gated download endpoint. The previous change made `useFileUpload`'s `link` field unconditionally return `/api/attachments/<id>/download` whenever the upload had an id. But `useFileUpload` is also used by avatar / logo pickers (account-tab, workspace-tab, agents/avatar-picker, squads/squad-detail-page) that persist `result.link` directly into `avatar_url`. Avatars are referenced cross-workspace (mention chips, member lists, inbox items), so binding their URL to a workspace-membership-gated endpoint would silently break cross-workspace avatar visibility. The fix splits the URL into two semantically distinct fields: - `link` — same as `att.url` (legacy contract). Avatar / logo callers continue to use this and remain on whatever URL semantics the storage backend dictates. - `markdownLink` — the stable per-attachment URL `/api/attachments/<id>/download`. Only the editor's markdown-persisting flow consumes this. Falls back to `link` for the no-workspace upload branch (where there is no attachment-row id to address). `editor/extensions/file-upload.ts` switches `image.src` and `fileCard.href` to `markdownLink ?? link` so comment markdown gets the stable shape while avatar callers stay on `link` unchanged. (2) Make the render-time img src loadable for token-mode clients. Persisting the stable `/api/attachments/<id>/download` URL fixes the expiry problem but the path itself sits behind `middleware.Auth`, which expects either a `multica_auth` cookie or a Bearer token in `Authorization`. Native `<img>`/`<video>` resource loads from token-mode clients (Electron's default mode, the mobile app, legacy-token web sessions) cannot attach the Authorization header, so the bare URL would 401 immediately rather than 30 minutes later. `Attachment.normalize` now runs the resolved record through a new `pickInlineMediaURL` helper that returns: - `record.download_url` when it's an absolute URL with a recognised CDN signature query (CloudFront-signed `Signature` / `Expires` / `Key-Pair-Id`, or `X-Amz-Signature` for raw S3 presigns) — these load as native resource src in any client. - else `record.url`, which on the LocalStorage backend carries a freshly-minted `/uploads/<key>?exp&sig` query whose signature IS the auth (token-mode-loadable). On non-CF S3 backends this is the raw stored URL — same behaviour as today. - else the original input URL (legacy / unresolved markdown keeps its existing path). This gives the same effect for both `kind: "record"` and `kind: "url"` attachment inputs: once a record is in hand, the rendered media src is whichever URL the current backend exposes a working signature on. Tests: - New `file-upload.test.ts` regression pinning that `markdownLink` is what lands in the markdown body when the upload result returns both a short-lived storage URL and a stable download path. - Updated `attachment.test.tsx` to reflect the new render-time swap (the rendered img src now follows the freshly signed URL, not the raw storage URL) and added a record-mode regression pinning the LocalStorage default — when `download_url` is the bare /api/attachments/<id>/download path, the renderer must fall through to the signed `record.url`. - Updated `chat-input.test.tsx` makeUpload helper for the new `markdownLink` UploadResult field. - 1222 frontend views tests + 507 core tests + typecheck across @multica/{core,ui,views} all pass. Refs: MUL-3130, GitHub issue #3891. Builds on |
||
|
|
dfc159e1aa |
feat: skip agent triggering on /note-prefixed comments (MUL-3115, #3649) (#3885)
* feat(comments): skip agent triggering on /note-prefixed comments A comment whose first token is the reserved /note prefix (case-insensitive) is stored like any other comment but never wakes an agent. The guard sits at the top of triggerTasksForComment, the single chokepoint, so it covers all three trigger paths — assignee, squad leader, and @mentioned agents. Gating only shouldEnqueueOnComment (as originally proposed) would still let "/note @agent ..." through the mention path. Lets members leave human-only tips/notes on agent-assigned issues without burning an agent run. MUL-3115, closes #3649. Co-authored-by: multica-agent <github@multica.ai> * feat(editor): add /note built-in slash command to comment composer Enable the `/` menu in the issue comment and reply composers in a new "command" mode that lists fixed built-in commands instead of the chat skill picker. Currently one command, /note, which marks a comment as a human-only note that won't trigger the assigned agent. Selecting it inserts the plain-text "/note " prefix (not a rich node), so a menu pick and a hand-typed command are byte-identical and the backend detects either with a simple prefix match. The command menu renders nothing on a non-matching `/` (hideOnEmpty) so typing a date like 6/8 isn't noisy. The chat skill picker is unchanged. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> * refactor(editor): match /note by label prefix and localize its description Address PR review feedback: - buildBuiltinCommandItems now matches the command label as a prefix only, dropping the description substring match copied from the skill picker. With one command this keeps the menu predictable (/no surfaces note; /deploy or a description word like /agent shows nothing) and avoids Enter selecting note unexpectedly. - The command description is now a localized UI string: added slash_command.commands.note to all four editor locales (en/ja/ko/zh-Hans) and the menu renders it via the typed translator. The /label itself stays literal since it's the typed token the backend matches. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> * fix(editor): shorten /note command description to avoid truncation The slash menu item is single-line (truncate, w-72), so the longer copy was cut off. Shorten to "won't trigger any agents" across all four locales — also more accurate, since /note skips assignee, squad leader, and @mentioned agents, not just the assigned one. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
25104d1855 |
perf(editor): parse large markdown in chunks to fix O(n²) freeze (#3823)
@tiptap/markdown parses via marked, whose tokenizer is O(n²) in document length. Opening a large markdown doc (issue description, agent instructions, …) froze the UI for tens of seconds: a 533KB plain-text doc took 61.8s to parse while the subsequent ProseMirror setContent was only 40ms. Upgrading marked doesn't help — already on 17.0.5, whose fix only covers `_`/`*` delimiter runs, not general prose. Parse large markdown in chunks instead of in one shot: split on blank lines outside fenced code blocks, parse each chunk independently, then concatenate the resulting docs. This drops marked's cost to O(n²/k) while producing a byte-identical document. Applied transparently at ContentEditor's two parse entry points (mount + WS-driven re-parse), gated at 50KB so normal small docs stay on the single-parse fast path. 533KB: parse 61.8s -> 0.95s (65x), open 100s -> 3.2s (31x). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d6540a1869 |
fix(clipboard): support copy over http:// via execCommand fallback (#3810)
navigator.clipboard is only exposed in a secure context (https or localhost). On self-hosted instances served over plain http:// it is undefined, so every copy / "copy all" / export button silently failed and left the clipboard empty (GitHub #3781). Add a shared copyText(text): Promise<boolean> helper in @multica/ui/lib/clipboard that prefers the async Clipboard API and falls back to a hidden <textarea> + document.execCommand('copy') for non-secure contexts. Migrate all direct navigator.clipboard.writeText call sites (code blocks, agent transcript copy-all, token / webhook / issue-link copy, etc.) to it, gating success side-effects on the returned boolean, and remove the now-redundant copyMarkdown wrapper. Secure-context users keep the native path unchanged. MUL-3068 Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |