* feat(skills): structured conflict + overwrite path for local skill re-import
Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.
- New terminal import status `conflict` carrying { existing_skill_id,
existing_created_by, can_overwrite }; can_overwrite = requester is the
skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
existence (workspace-scoped) and creator permission, then replaces
description/content/config and fully replaces files (pruning files absent
from the new bundle). Preserves id, created_by, created_at, name, and
agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
create-by-name); any mid-write error rolls back the tx, leaving the original
skill untouched. Retrying a terminal request is a no-op.
Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.
Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name
Addresses review feedback on PR #3498 (MUL-2800).
Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.
Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.
Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.
Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): resolve local import conflicts in desktop
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve bulk flow after conflict resolution
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): show creator name instead of UUID in import conflict UI
When a local skill import hits a name conflict with a skill owned by
another user, the locked-creator message rendered the raw
existing_created_by UUID via the {{creator}} placeholder, which is
unreadable.
Resolve the UUID against the workspace member list and render the
display name instead. When the creator has left the workspace (or the
member list hasn't loaded), fall back to the unbranded conflict_locked
message rather than leak the UUID.
Adds two test cases covering both branches.
MUL-2701
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
Two bugs prevented the Lark binding flow from completing for already-logged-in users:
1. The useEffect ran before AuthInitializer's getMe() returned, setting state to
needs-auth; the guard then blocked re-entry once auth loaded.
2. The sign-in redirect used ?redirect= but the login page reads ?next=.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).
All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): structured conflict + overwrite path for local skill re-import
Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.
- New terminal import status `conflict` carrying { existing_skill_id,
existing_created_by, can_overwrite }; can_overwrite = requester is the
skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
existence (workspace-scoped) and creator permission, then replaces
description/content/config and fully replaces files (pruning files absent
from the new bundle). Preserves id, created_by, created_at, name, and
agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
create-by-name); any mid-write error rolls back the tx, leaving the original
skill untouched. Retrying a terminal request is a no-op.
Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.
Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name
Addresses review feedback on PR #3498 (MUL-2800).
Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.
Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.
Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.
Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): resolve local import conflicts in desktop
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve bulk flow after conflict resolution
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add skill import conflict strategies
Co-authored-by: multica-agent <github@multica.ai>
* fix(i18n): sync skill import locale keys
Co-authored-by: multica-agent <github@multica.ai>
* docs: explain skill import conflict handling
Co-authored-by: multica-agent <github@multica.ai>
* docs: refresh skill import source map anchors
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The preview answer depends on live queue state (pending-task dedup), not
just the mention set, so three staleness bugs showed up around it:
- staleTime: Infinity pinned a "nobody triggers" snapshot taken while
the mentioned agent was still queued — the chip never appeared even
though sending really did wake the agent (create recomputes).
-> staleTime: 0, cached signatures revalidate in the background.
- The in-flight gap on a signature change rendered as an empty agent
list, flickering the chips and wiping the composer's suppressed-id
set via the pruning effect. -> placeholderData: keepPreviousData.
- Nothing refreshed an open composer when an agent's task finished.
-> the WS task-lifecycle handler now also invalidates the
commentTriggerPreviewAll prefix, so chips appear mid-typing the
moment the agent becomes triggerable again.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Five chip states get distinct copy instead of sharing one sentence and a
vague "not this time":
- single, will trigger: Starts working when sent (unchanged)
- single, skipped: Won't be triggered
- several, k will fire: {{count}} agents start working when sent —
the count covers only non-suppressed agents; skipped ones read as the
dimmed heads in the stack next to the number
- several, all skipped: No agents will be triggered
- popover row state: Skipped
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
- Copy: one fixed sentence for single and stacked chips — the avatar(s)
carry who and how many, the text carries condition + outcome
("发送后开始工作" / "Starts working when sent"), killing the
"is it already running?" misread. Drops the per-name and count keys.
- Color: sidebar-style resting state — muted-foreground until hover so
the strip reads as metadata, not content.
- Motion: pure fade-in (no slide offset).
- Spacing: reply composer reserves pb-9 so the chip strip reads as a
footer instead of a second content line glued to the text.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The issue description editor bound pending uploads with
`md.includes(a.url)`, but the editor persists the durable markdownLink
(`/api/attachments/<id>/download` / markdown_url), never the raw storage
`a.url`. The filter therefore never matched, so description uploads were
never linked via `attachment_ids`.
After reload the attachment was absent from `issueAttachments`, so the
renderer could not resolve it to a freshly-signed CDN `download_url` and
fell back to the persisted auth-gated download endpoint. That endpoint
loads on web (same-site cookie / proxy) but fails as a native <img> on
Desktop/Electron (cross-origin file:// renderer carries no auth), leaving
the image broken — while comments rendered fine because they already bind
via contentReferencesAttachment.
Switch the description binding to contentReferencesAttachment, matching
the comment/reply/chat composers, so description images resolve to the
signed CDN URL on every client. Add a regression test pinning the
absolute-host markdown_url shape.
* Add comment trigger preview suppression
Co-authored-by: multica-agent <github@multica.ai>
* Use TanStack Query for trigger preview
Co-authored-by: multica-agent <github@multica.ai>
* Test note comments skip create triggers
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): redesign comment trigger chips as avatar chips
Single agent renders as avatar + presence dot + full sentence; several
agents collapse to an overlapping stack + active count, mirroring the
header working chip. Per-agent skip moves into a click-opened popover
(hover layers stay read-only tooltips); suppression reads as brightness,
not a ban glyph. Loading and preview errors render nothing.
Also: share one tooltip body across chip and popover rows, invalidate
cached previews after a comment lands (the enqueued task changes the
dedup answer), move the preview query key into issueKeys, and drop the
now-unconsumed status field from useCommentTriggerPreview.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(server): drop comment trigger wrappers kept only for tests
enqueueMentionedAgentTasks and shouldEnqueueSquadLeaderOnComment had no
production callers after the compute/enqueue split — the comment path
goes through computeCommentAgentTriggers. Tests now exercise the compute
functions directly via package-local helpers, so the legacy adapters
cannot drift from the real path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(skills): sync mentioning/squads source maps with shared trigger computation
The squads source map still pointed the comment-trigger contract at the
pre-refactor call chain (comment.go:940 -> shouldEnqueueSquadLeaderOnComment),
and the mentioning skill referenced the deleted wrapper. Re-anchor both
to computeCommentAgentTriggers / computeAssignedSquadLeaderCommentTrigger
/ computeMentionedAgentCommentTriggers with current line numbers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
* fix(realtime): invalidate per-issue caches on WS reconnect (MUL-3189)
Per-issue caches (timeline, reactions, subscribers, usage, attachments,
tasks) are keyed without wsId, so the issueKeys.all(wsId) prefix in
invalidateWorkspaceScopedQueries never reached them. With the
staleTime: Infinity default they rely entirely on WS events for
freshness, so a comment:created event lost during a disconnect (e.g.
macOS sleep) left the timeline stale until a full view reload — the
inbox showed the agent's new comment while the issue's comment area
stayed empty.
Add *All prefix helpers for the per-issue key families and invalidate
them in the reconnect / WS-instance-change recovery path. Inactive
caches are only marked stale and refetch on next mount; the mounted
issue refetches immediately, matching its existing useWSReconnect
behavior, so this does not reintroduce the MUL-1941 memo thrash.
Fixes#3953
Co-authored-by: multica-agent <github@multica.ai>
* refactor(core): define issueKeys.tasks via tasksAll prefix helper
Review nit on #3992 — keep the per-issue key families consistently
defined in terms of their *All prefix helpers. No behavior change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
A thread could hold multiple resolved comments at once: ResolveComment
was a plain per-row setter that never cleared the prior resolution, and
"replacing" one was a display-only illusion (deriveThreadResolution
picks the max resolved_at). The stale rows stayed resolved in the DB and
the optimistic update flashed the new resolution, then reverted.
Make single-resolution-per-thread a write invariant:
- ClearOtherThreadResolutions: thread-scoped clear via a RECURSIVE CTE
(root + descendants of the target, id <> target), returns each cleared
row.
- ResolveComment handler runs the clear + set in one tx so the replace
is atomic. It emits comment:unresolved per cleared sibling (granular
realtime consumers patch a single comment in place and would otherwise
keep showing the stale resolution). Target keeps its COALESCE
idempotency and the re-resolve event suppression.
- Frontend optimistic update mirrors the invariant: resolving clears
every other resolution in the same thread, so the cache never shows
two at once. Unresolve still only clears its own row.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilots): show creator in autopilot detail properties (MUL-3139)
The autopilot creator was already persisted end-to-end (created_by_type /
created_by_id on the autopilot table, exposed via AutopilotResponse and the
frontend Autopilot type) but never rendered. Add a "Created by" field to the
detail page Properties section, mirroring the existing assignee field and the
issue-detail creator row, reusing ActorAvatar + getActorName.
Creator may be a member or an agent (the HTTP create path stamps member today,
but backend logic also writes created_by_type=agent), so the display resolves
both actor types and does not assume member. List rows are intentionally left
unchanged, matching the issue convention (creator lives in detail, not lists).
Adds the field_created_by label to all four locale bundles (en/zh-Hans/ja/ko);
locale parity test enforces full coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilots): show creator in autopilots list (MUL-3139)
Add a Created by column to the autopilots list, mirroring the detail
page. Secondary columns (creator, mode, last run) are hidden below lg
so small screens keep only name, agent, and status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(transcript): reuse taskMessageToPayload in WS broadcast
The ReportTaskMessages WebSocket broadcast hand-built the payload and
duplicated the created_at formatting that taskMessageToPayload already
does. Reuse the helper with the just-inserted row, which carries the
same redacted values and the DB-assigned timestamp.
Co-authored-by: multica-agent <github@multica.ai>
* test(transcript): cover coalesce created_at behavior
Lock in that coalescing streaming fragments carries the latest
created_at, and falls back to the previous timestamp when the merged
fragment has none.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Threads the existing task_message.created_at column through the full stack (Go protocol -> REST/WS handlers -> TS types -> transcript dialog) so agent run transcripts show per-entry timestamps, helping users spot stalled runs. Additive, no migration.
* feat(issues): per-comment thread resolution with sticky collapse
Allow resolving any comment, not just roots. Resolving a root folds the
whole thread into one bar (existing); resolving a reply marks it as the
thread's resolution ("Resolve thread with comment") and folds the other
replies behind a "N comments" bar, with the resolution kept visible and
badged. Which comment is the resolution is a pure frontend derivation
(root wins, else latest resolved reply), so no write-side bookkeeping is
needed and any resolved_at combination renders one resolution.
- backend: drop the "only root comments can be resolved" guard
- views: deriveThreadResolution + reply-resolution rendering, sticky
collapse/fold bars (overflow-clip on the card so sticky resolves to the
timeline scroll parent), scroll the folded thread back into view on
collapse, ListChevronsDownUp icon, locales (en/ja/ko/zh-Hans)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(issues): sticky comment headers for long comments
Pin each comment's header (root + replies) to the timeline's scroll
parent while reading, so a long comment keeps its author + actions
visible instead of scrolling out of reach. Exactly one header is pinned
at a time:
- Reply headers stick within their own CommentRow box (release at the
reply's end).
- The root header is wrapped in a root-section container so its sticky
containing block spans only the header + root body — without it the
containing block is the whole thread and the root header stays stuck
behind every reply. Replies render outside the wrapper, gated on open.
- Skip the root header sticky whenever a resolution collapse bar already
owns the top-0 slot (root resolved+expanded, or reply-resolution
expanded) to avoid two bars stacking at the same offset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening an inbox comment notification on an issue with a running agent
shoved the whole desktop page — header included — off the top, and no
amount of scrolling brought it back; only toggling the right sidebar
(which reflows the panel group) restored it.
Root cause: the deep-link landing uses native
scrollIntoView({block:"center"}) on the target comment. Native
scrollIntoView is spec'd to scroll EVERY scrollable ancestor. On a cold
mount where the timeline is still streaming (is-working) and the sidebar
panel starts collapsed, the inner timeline scroller can't center the
target on its own, so the scroll propagates up and scrolls the desktop
shell's overflow:hidden wrapper (desktop-layout.tsx). That wrapper has no
scrollbar and doesn't auto-clamp, so the page stays shoved up until a
resize reflows it. Desktop-only: web sits in a document-scroll context
with a real scrollbar that self-corrects.
Fix: drive the timeline container's scrollTop directly and re-center
across rAF frames until async heights settle, instead of leaning on the
ancestor scroll. The scroll never touches an ancestor, so the header can
no longer be pushed off-screen.
Tests assert the user-facing contract (lands on + highlights the target
comment) rather than the scroll mechanism, which jsdom can't lay out.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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 a740f7a35.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3130: chat upload map keys on persisted markdownLink, not the short-lived link
GPT-Boy's second-round review on PR #3937 caught a chat-only blocker
left over from the previous fix.
After the previous commit split `UploadResult.link` into `link`
(legacy avatar/logo URL) and `markdownLink` (stable per-attachment
URL persisted into markdown), the comment editor's image src + file
card href correctly switched to `markdownLink ?? link`. But chat
input still kept the upload-map key on the old `link`:
uploadMapRef.current.set(result.link, result.id)
…
if (content.includes(url)) activeIds.push(id)
In the LocalStorage backend `link` is the short-lived
`/uploads/<key>?exp=&sig=` URL. The editor persists the stable
`/api/attachments/<id>/download` URL into the message body, so
`content.includes(url)` never matches and the send call drops
`attachment_ids`. The attachment ends up bound only to the chat
session, not to the message — agents reading message-level metadata
see no attachments.
Fix: key the upload map on the same value the editor actually wrote
into the markdown body (`markdownLink || link`). The
`content.includes(url)` check then matches and the attachment id is
correctly forwarded on send.
Tests:
- Updated the chat-input mock editor to insert `markdownLink || link`
into its value, mirroring the real editor's persisted-URL choice
(uploadAndInsertFile in editor/extensions/file-upload.ts). Without
this the mock would silently paper over the bug.
- Added a regression test where the upload result returns a
short-lived `link = /uploads/...?exp&sig` and a stable
`markdownLink = /api/attachments/<id>/download`. Asserts (a) the
message body carries the stable URL and never the signed query,
and (b) the bound `attachment_ids` includes the attachment id.
All 1223 frontend views tests pass (was 1222, +1 new regression).
Typecheck and 507 core tests still green.
Refs: MUL-3130, PR #3937 review by GPT-Boy. Builds on f66a522d0.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): wire agy --model and model discovery for Antigravity
agy 1.0.6 added a --model flag and an `agy models` catalog command, which
were the #1 blocker in the earlier agy-backend review (MUL-3125). The
antigravity backend already shipped but deliberately dropped opts.Model
because agy 1.0.1 had no way to select a model.
- buildAntigravityArgs now passes --model <display name> when opts.Model is
set; the value is the exact `agy models` display string (spaces + parens),
passed as a single exec arg so no shell quoting is needed.
- Block --model in custom_args so it can't override the managed value.
- ListModels("antigravity") enumerates via `agy models` (no static fallback:
agy silently no-ops on unrecognised models, so a stale guess would turn a
typo into a successful empty run).
- ModelSelectionSupported now returns true for every built-in provider; the
hook stays for any future model-less runtime.
- Daemon probe reads MULTICA_ANTIGRAVITY_MODEL for the daemon-wide default.
Co-authored-by: multica-agent <github@multica.ai>
* docs(providers): mark Antigravity model selection as supported
Antigravity gained --model in agy 1.0.6 (MUL-3125). Update the provider
matrix + prose (en/zh/ja/ko) from "managed internally / no --model" to
dynamic discovery via `agy models`, and refresh the now-stale picker
comments. Flag the display-string (not slug) shape and agy's silent no-op
on unrecognised values.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): reject unknown Antigravity model at spawn (MUL-3125)
agy exits 0 with empty output on an unrecognised --model, so a stale/typo'd
value would surface as a 'completed' but empty task. Validate opts.Model
against the `agy models` catalog in Execute before spawning: a non-empty
model the CLI does not advertise fails fast with an actionable error listing
the real choices. opts.Model is the single funnel for agent.model and the
MULTICA_ANTIGRAVITY_MODEL default, so this one check covers every source
(UI free-text, API, persisted value, env) — addressing Elon's review that a
UI-only guard is bypassable.
Validation is fail-OPEN: if the catalog can't be discovered we pass the
value through and let agy resolve it, so a discovery hiccup never blocks a
run. Pure antigravityModelError() is unit-tested (valid / unknown / near-miss
/ empty-model / empty-catalog); verified live against real agy 1.0.6.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): move agent live signal into the issue-detail header
Replace the in-body sticky "agent is working" card (AgentLiveCard) with a
compact chip in the issue-detail header, so the live signal sits in one
fixed place and never competes with sticky banners in the content column.
- New IssueAgentHeaderChip: avatar(s) + live-ticking blue elapsed time;
click opens a popover listing every active task.
- Popover reuses ExecutionLogSection's ActiveTaskRow (now exported) so the
popover and the right panel are literally the same row — no duplication.
- PopoverContent gains an optional keepMounted so the row's confirm dialog
survives the popover closing on Stop.
- Running rows in ExecutionLogSection drop the blue spinner for a
live-ticking blue elapsed timer (panel + popover share this).
- Source the chip from the workspace agent-task snapshot filtered by issue
(same source as board/list indicators, zero extra network); delete the
old AgentLiveCard + its test and its heavy per-issue WS machinery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(issues): live event count on the agent chip + execution-log rows
Show a live "N events (elapsed)" on running agents, consistent across the
header chip, its popover rows, and the right-panel execution log.
- Read the shared per-task message cache (taskMessagesOptions, kept live by
useRealtimeSync's global task:message handler) instead of a bespoke
subscription — one source of truth, deduped across chip / popover / panel /
transcript, no extra WS wiring.
- Extract <RunningStat> (event count in info-blue + elapsed in muted parens)
so all surfaces render the running stat identically.
- ExecutionLogSection running rows now show the same "N events (elapsed)";
the transcript opened from them streams live from the shared cache.
- Chip: single running shows events (elapsed); multiple shows "N working".
- i18n: add agent_live.event_count (4 locales).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
The in-app inbox (sidebar badge, real-time WS updates, settings, inbox
page) was already shared and worked on web. The only Desktop-only piece
was the native OS banner: handleInboxNew called desktopAPI.showNotification,
which is undefined on web, so no banner fired for new inbox items while the
app was unfocused.
Add the browser equivalent, keeping handleInboxNew as the single decision
point (focus + source-workspace mute gating stays shared with desktop):
- packages/core/platform/system-notification.ts: browser Notification engine
(showWebNotification) + permission helpers + a click-handler registry. Lives
in core (the caller does) but injects the click-routing decision so core
stays headless.
- handleInboxNew: branch desktopAPI (unchanged) → else showWebNotification.
- apps/web WebNotificationBridge: registers click routing to the source
workspace's inbox (?issue=…), mirroring desktop's DesktopInboxBridge.
- Settings → Notifications: web-only opt-in to grant browser permission
(hidden on desktop / where the API is unavailable); en/zh-Hans/ja/ko.
Permission is an explicit settings opt-in (no auto-prompt on load, per
browser best practice). Tests cover the engine and the web path in
handleInboxNew.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The sticky header in the Projects compact list was missing backdrop-blur,
causing underlying content to bleed through the semi-transparent bg-muted/30
background when scrolling. Matches the DataTable header pattern used in the
Agents module.
Co-authored-by: multica-agent <github@multica.ai>
The per-agent "CLI" column rendered the shared multica daemon `cli_version`
from each runtime's metadata. That value is the version of the multica
daemon binary and is identical for every agent registered by one daemon, so
Claude / Codex / Gemini / Opencode all displayed the same number (e.g.
v0.3.17) even though each tool has its own version (#3838).
Each runtime already reports its own underlying CLI tool version in
`metadata.version` (e.g. "2.1.5 (Claude Code)", "codex-cli 0.118.0"). The
column now shows that. The multica daemon CLI version and its update prompt
stay where they belong — the machine meta strip and the detail page's
UpdateSection — so the per-row multica update arrow (which compared against
the latest multica release) and its now-unused i18n strings are removed.
MUL-3097
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Mainland Feishu binding works; only the newly-added Lark (international,
open.larksuite.com) install path is unreliable — some Lark installs
complete on Lark's side but never persist a lark_installation row (no WS,
no inbound, no task). Hide just the "Bind to Lark" CTA behind a single
LARK_INTL_CONNECT_ENABLED flag and leave the "Bind to Feishu" entry, the
settings panel, and all existing-installation management untouched.
Flip LARK_INTL_CONNECT_ENABLED back to true to restore the Lark CTA;
nothing else changes. Temporary measure while the Lark install-landing
bug is investigated.
- LarkAgentBindButton: the Lark button is gated by the flag; the Feishu
button and the Connected badge / Manage / Disconnect are unchanged.
- Tests: the CTA tests assert Feishu shown + Lark hidden; the Feishu
click-to-begin (region=feishu) test stays; the Lark click test was
removed (no button) and noted for restore; the dialog polling-error
tests open via the Feishu CTA.
MUL-3083
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)
The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.
Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.
Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
and onto runPolling's initial region local. SwitchedDomain still
flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
with empty defaulting to feishu for back-compat.
Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
a single dialogRegion useState so an "open but with no region picked"
intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
region-aware copy (title, description, scan hint, link fallback,
success toast).
i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
install_scan_hint_*, install_open_link_fallback_*, and
install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
single-region keys are kept for now; nothing in the tree references
them anymore but a follow-up cleanup can remove them once the dust
settles.
Tests
- Two new lark.RegistrationClient tests pin region routing in both
directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
beginLarkInstall with the matching region argument. Existing CTA
tests updated to expect both buttons in place of one.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu
Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.
Backend (must-fix from review)
The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.
- PollResult now carries SwitchedDomain AND SwitchedRegion in
lockstep, so the caller never has to re-derive region from the
domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
host symmetrically with the existing tenant_brand=lark check, gated
on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
hardcoded RegionLark — the SwitchedDomain branch now flips both
feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
(table-driven, both directions) to pin that the gate doesn't loop.
Backend (nit from review)
Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).
Frontend (nit from review)
The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:
- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}
across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.
Desktop (separate report)
Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.
context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
The agent Lark binding surfaced the same connect/disconnect affordance in
two places on one page — the left inspector's INTEGRATIONS section and the
right pane's Integrations tab both rendered the full LarkAgentBindButton,
so the destructive Disconnect lived in two spots.
Split by role:
- Inspector (left): a compact, read-only status row (green dot + region
chip + "Connected to Lark") that deep-links into the Integrations tab.
New LarkAgentBotStatusRow, opted into via LarkAgentBindButton's
onShowConnectedDetails prop.
- Integrations tab (right): keeps the full badge, now the single home for
Manage / Disconnect. The badge itself is reworked to a two-row layout —
status (left) + soft `destructive`-variant Disconnect (right) on row 1,
"Manage in Lark" demoted to a muted secondary link on row 2.
Cross-sibling navigation goes through a one-shot navIntent channel on
AgentOverviewPane that routes via requestTabChange, so the unsaved-changes
guard still fires when jumping from the inspector.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(issues): remove comment composer expand control
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): auto-grow composers and highlight reply submit when ready
- Drop max-height cap on comment + reply composers so they grow with content
- Reply send button turns primary when there is submittable text
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface a Disconnect/Unbind action in LarkAgentBotConnectedBadge so
owners and admins can remove a Lark Bot binding directly from the
agent inspector — no detour to Settings. The button sits next to the
existing 'Manage in Lark' link and is intentionally rendered as a
quieter muted-foreground control with a hover-destructive accent so
it doesn't compete visually but stays discoverable.
Confirmation is mandatory: a small AlertDialog reuses the existing
disconnect_confirm_* i18n strings. The action calls
api.deleteLarkInstallation, invalidates larkKeys.installations(wsId)
on success so the parent re-renders the Bind CTA, and toasts
success/failure. Cancel is disabled while the request is in-flight
to prevent racing the close.
Tests cover button visibility, confirm gating, success path (delete
called with correct args, cache invalidated, toast), error path (no
invalidate, toast.error), and Cancel-disabled behaviour.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
@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>
Follow-up to #3797. The inbox:new handler keys the notification-preference
query on item.workspace_id, but the request itself still resolved its
workspace from the active-workspace X-Workspace-Slug header. On a cold
cache, a user viewing workspace B who received a workspace-A notification
read B's mute setting and cached it under A's key — so A's banners could
fire while muted (and vice-versa), polluting A's cache.
Add an optional workspaceSlug override to getNotificationPreferences and
notificationPreferenceOptions, and pass the resolved source slug from the
inbox:new handler. When the source slug can't be resolved, read only an
already-warm cache instead of fetching with the wrong workspace. Tests
cover the cold-cache source-slug fetch, source mute suppression, and the
no-fallback guard.
MUL-3062
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): serve Feishu and Lark from one deployment, per installation
The Lark integration was locked to a single open-platform host chosen
deployment-wide (MULTICA_LARK_HTTP_BASE_URL / _CALLBACK_BASE_URL,
defaulting to open.feishu.cn), so one deployment could talk to only the
mainland Feishu cloud OR Lark international — never both. Teams on the
other tenant could not use the integration at all.
Make the host per-installation. The device-flow installer already
auto-detects the tenant (Lark emits tenant_brand="lark" mid-poll); we now
persist that as lark_installation.region, carry it on
InstallationCredentials.Region, and resolve the open-platform host per
call (REST + WS bootstrap) from the region. An explicit cfg.BaseURL
(env / httptest) still overrides every region, so existing tests and
staging/proxy setups keep working.
- migration 116: lark_installation.region TEXT NOT NULL DEFAULT 'feishu'
CHECK (region IN ('feishu','lark')) — existing rows are all mainland.
- lark.Region enum + OpenPlatformBaseURL/RegionOrDefault helpers.
- registration: thread the detected region into finishSuccess so the
install-time GetBotInfo hits the right cloud AND the row records it.
- every credential-build site (patcher, replier, WS provider, union_id
backfill) copies region off the installation row.
- region is part of the WS supervisor fingerprint so a re-install that
switches cloud restarts the connection.
- API: surface region on the installation listing DTO.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): surface installation region in settings UI
Read the per-installation region off the listings response: build the
"Manage in Lark" dev-console host from it (open.feishu.cn vs
open.larksuite.com instead of a hardcoded mainland host) and render a
Feishu / Lark badge on each connected bot. The field is optional and
defaults to Feishu when an older server omits it (API-compat). Adds the
region_feishu / region_lark labels to all four locales.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* docs(lark): document simultaneous Feishu + Lark support
The cloud each bot belongs to is now auto-detected at install and stored
per installation, so one deployment serves both. Replace the old
"point MULTICA_LARK_HTTP_BASE_URL at larksuite for international tenants"
guidance (now just an optional override) in all four locales.
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): repair legacy Lark-international installs on upgrade
Review follow-up (MUL-3083). Migration 116 backfilled every existing
lark_installation to region='feishu', assuming all historical rows were
mainland. But self-host deployments could already run Lark international
via the deployment-wide MULTICA_LARK_HTTP_BASE_URL override, so those
rows are really Lark — clearing the override after upgrade (which the new
docs invite) would route them to open.feishu.cn and break them.
Add a one-shot startup repair, BackfillRegionFromLegacyOverride, fired
off the hot path like BackfillBotUnionIDs: when the deployment's global
base-URL override targets open.larksuite.com, relabel the still-default
'feishu' rows to 'lark'. Gating on the deployment-wide override is what
makes it safe — every pre-existing install on such a deployment was Lark.
Idempotent; no-op on mainland / fresh deployments. Verified end-to-end
against a scratch DB (flip then 0-row idempotent re-run).
Also document that a Lark/飞书 app_id is globally unique across both
clouds, which is what makes the app_id-keyed token cache and the
UNIQUE(app_id) constraint safe across regions (review nit).
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
* docs(lark): fix ops guidance to match auto per-installation region
Review follow-up (MUL-3083). .env.example and docker-compose.selfhost.yml
still told operators that international Lark requires pointing both base
URLs at open.larksuite.com — now wrong, and it would push a fresh
deployment back into a single-cloud override. Rewrite them: the base
URLs are optional deployment-wide overrides; normal dual-cloud operation
keeps them empty. Document the first-boot auto-relabel for deployments
migrating off the old single-cloud override, across the integration docs
(en/zh/ja/ko).
MUL-3083
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Resolves the desktop inbox notification slug from the item's own workspace_id, routes the click through the navigation adapter for a real workspace switch, and invalidates the source workspace's inbox cache. Follow-up: mute-preference fetch should also target the source workspace.
Closes#3766