Rework the create-space page into a Linear-style settings form: a
"Basic information" card with label-left / control-right rows for
icon+name and identifier (short one-line hint, full rules only on error),
validation that stays quiet until submit, and a Members section that
mirrors the space detail page's picker (outline-button trigger with
chevron, no card wrapper). Adds section_basics / key_hint_short /
member_count_solo locale strings across all four locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the Space `description` field across the whole stack: the
workspace_space column (migration 150), sqlc queries + generated code,
the handler request/response, core types + zod schema, the `multica space`
CLI `--description` flag, and the create/detail forms + i18n. Spaces keep
name / key / icon; workspace / project / issue descriptions are untouched.
Also reverts the short-lived "inject Space description into the agent
brief" chain (space_description through the daemon task pipeline and the
`multica space get` CLI) — with the field gone there is nothing to inject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Space Identifier (key) is now editable on the space detail page, and no
longer frozen after the first issue: renaming bulk-writes issue-identifier
aliases under the old key (BackfillSpaceKeyAliases) so every OLDKEY-N
reference keeps resolving, and the UI confirms the rename when the space
already holds issues. Admin-only; navigates to the new /space/<key> URL.
Workspace settings:
- drop the obsolete "Issue prefix" field — numbering is per-space now, and
identifiers come from the space key (edited on the space detail page).
- make the workspace slug (its URL) editable: validates pattern/reserved,
409 on collision, confirms the link-breaking change, and migrates to the
new /<slug> URL on save.
UI: add an `underline` variant to the shared Input (was a copy-pasted
override string in three places).
Agent brief: inject the Space description into the run brief alongside the
project/workspace context (space_description through the daemon task chain);
add `multica space get` so an agent can read a space's description on demand.
Known gap: quick-create tasks don't yet carry space_description (their
QuickCreateContext snapshot lacks it) — deferred.
Also bundles in-tree WIP present on this branch (space-first sidebar,
onboarding workspace step).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue/project/autopilot each declare their own space(s) with no
cross-validation — an issue's space need not belong to its project's
space set. Removes:
- backend: ProjectSpaceAmbiguous error, the EnsureProjectHasSpace side
effect, the 409 space_reassignments reconcile + renumber, and the
now-orphaned CountProjectIssuesBySpace / ListIssuesByProjectAndSpace
queries; ResolveSpace falls back to the workspace default for a
multi-space project instead of erroring.
- frontend: both SpaceProjectConflictDialog wirings and the project-side
ProjectSpaceReassignDialog, plus the conflict schema/types/i18n.
Data structures unchanged (project_space, issue.space_id,
autopilot.space_id); multi-space projects stay, managed inline via
SpaceMultiPicker on the project detail.
Also bundled from this branch's working tree:
- create-space: derive the key from the name, no silent "T" prefix
- space detail: normal-colour description, shadcn members trigger
- invitations: space_ids so invitees join chosen spaces on accept
(migration 149)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the one real conflict (server/internal/daemon/execenv/runtime_config.go):
main's MUL-4297 retired the legacy verbose runtime brief in favor of the
slim brief as the only path. This branch had added a "## Space Context"
section and --space flag docs to the legacy builder; the slim builder
(runtime_config_sections.go) already carries the identical Space Context
section (writeSpaceContext) and --space references independently, so
taking main's side (delete the legacy branch) loses nothing — confirmed
by diffing this branch against main for the file before resolving.
Also fixes a real incompatibility main introduced after the conflict:
comment_reconcile_test.go (new on main, MUL-4195) creates issues via raw
SQL without space_id, which main's schema (space_id nullable) allows but
this branch's migration 132 (space_id NOT NULL) rejects. Added space_id
via a workspace_space subquery, matching the existing pattern in
workspace_scope_guard_test.go.
Verification: full go test ./... (isolated database, not the shared dev
DB) passes; go build/vet clean; core/views/web/desktop typecheck clean;
core (804 tests) and views (1703 tests) suites pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Migration 131 normalizes each workspace's legacy issue_prefix into a
Space key satisfying ^[A-Z][A-Z0-9]{0,6}$, but a prefix longer than 7
chars, digit-first, or punctuated gets rewritten and never went
through the alias-writing path in UpdateIssue (no issue actually
changed space_id, so no move-triggered alias was recorded). Old
links, CLI/API identifier lookups, and GitHub auto-linking for those
workspaces would 404/silently skip forever.
Backfill issue_identifier_alias for exactly the workspaces where the
normalized key diverges from the original prefix, and widen the
GitHub identifier regex back to {0,9} (matching the old prefix length
cap) so 8-10 char legacy prefixes are still extracted as candidates
before the DB/alias lookup decides validity.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(chat): LLM-generated chat session titles with silent fallback (MUL-4295)
Generate a concise, language-matched title for a chat session after the
first user message, replacing the raw first-message-derived title. The
work is best-effort and fully non-blocking:
- Triggered on the first user message in SendChatMessage (detected via
ChatSessionHasUserMessage before insert), run in a detached goroutine
so it never delays the send or first response.
- Reuses pkg/llm GenerateText on the configured default model
(MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini); no model from the client.
- Self-hosted with no LLM key (h.LLM.Enabled()==false): silent no-op,
the original title stands. Same on timeout / upstream error.
- CAS write (UpdateChatSessionTitleIfCurrent) so a manual rename during
generation is never clobbered and titling runs at most once.
- Pushes chat:session_updated so the frontend refreshes in place.
- sanitizeChatTitle strips quotes/brackets, 'Title:'/'标题:' prefixes,
trailing punctuation, and caps at chatSessionTitleMaxLen.
Tests cover all six cases: configured→semantic title, disabled→fallback,
upstream error→fallback, manual rename→no clobber, empty output→fallback,
idempotent second run, plus sanitize rules and the realtime push.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): panic-contain title goroutine + loop sanitizer to a fixed point (MUL-4295)
Address PR #5141 review (张大彪 / multica-eve, Phase B):
1. The detached title-generation goroutine now has a defer recover() at the
top of its body. It runs outside chi's Recoverer, so an unhandled panic
in GenerateText / sanitize / the DB write / publish would crash the
server process. Best-effort path: log and keep the original title.
2. sanitizeChatTitle now alternates prefix-stripping and wrapper-stripping
in a loop until the string is stable, so a forbidden label hidden inside
a wrapper ("Title: Fix login", 「标题:修复登录问题」) is fully cleaned
regardless of nesting order. Added both cases to the sanitize test table.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): fold trailing-punctuation trim into sanitizer fixed-point loop (MUL-4295)
Address PR #5141 follow-up review: the trailing-punctuation trim ran once
AFTER the prefix/wrapper loop, so a trailing '.' / '。' left the closing
wrapper unrecognized and the forbidden prefix untouched for inputs like
"Title: Fix login". and 「标题:修复登录问题」。. Trailing trim now runs inside the
same loop, so removing the trailing punctuation re-exposes the wrapper (and
the prefix it hid) on the next pass. Added both cases to TestSanitizeChatTitle.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The runtime_brief_slim feature flag has burned in; the slim runtime brief is now the sole path.
- execenv: buildMetaSkillContent / BuildCommentReplyInstructions delegate to the slim assembler unconditionally; delete the legacy verbose brief body and writeBackgroundTaskSafetyInstructions.
- Remove the runtime_brief_slim flag and the daemon-bound flag delivery subsystem built solely for it: execenv flag wiring (runtime_config_flag.go, server_snapshot_provider.go), the featureflagdispatch package, the DaemonFeatureFlagSnapshot heartbeat protocol field, and the server/daemon wiring in router.go, handler, daemon.go, main.go, cmd_daemon.go.
- Keep the generic server/pkg/featureflag engine (still used by composio_mcp_apps).
- Update tests to slim-only expectations and docs/feature-flags.md.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Project↔space had an asymmetry: attaching an issue whose space isn't in
a project's space set is intercepted (add the space, or move the
issue), but the reverse — removing a space from a project's set while
the project still has issues filed under it — was completely
unguarded, silently stranding those issues under a space the project
no longer lists.
UpdateProject now checks every space_ids removal against the project's
actual issues (CountProjectIssuesBySpace). A removal with no issues
under it proceeds as before. A removal with issues is rejected with a
structured 409 (project_space_has_issues, listing each conflicting
space + its issue count) unless the request's space_reassignments maps
every one of them to a space the project still lists — in which case
the affected issues are moved (renumbered, old identifier aliased) in
the same transaction as the space_ids update, and each gets its own
issue:updated broadcast so realtime-filtered lists reconcile like a
manual move would.
Extracted the single-issue move-to-space logic out of UpdateIssue into
service.MoveIssueToSpace so the batch path reuses it instead of
duplicating the counter/position/alias sequence.
Frontend: project's 3-dot menu gets a "Manage spaces" entry opening a
new two-step dialog (pick spaces → resolve conflicts with a per-space
move-target picker), replacing the disabled space property row that
project-detail.tsx carried since the space rollout. Dropped the now-dead
table.spaces locale key that row was the only user of.
Verification: new Go tests for the accept/reject/reassign/invalid-target
cases (server/internal/handler/project_space_reconcile_test.go) against
an isolated worktree database; full internal/... and cmd/migrate suites
pass. New dialog tests
(packages/views/spaces/components/manage-project-spaces-dialog.test.tsx)
cover the no-conflict, empty-selection, conflict-step, confirm-move, and
malformed-409 cases. core/views/web/desktop typecheck clean; views
locale parity test passes for en/ja/ko/zh-Hans.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
resolveIssueByIdentifier and GitHub's lookupIssueByIdentifier both fall
back to issue_identifier_alias when the primary space-key+number lookup
misses, so an old identifier keeps resolving after an issue moves
spaces. But both passed the parsed prefix straight through without
lowercasing it first. GetIssueBySpaceKeyAndNumber's SQL lower()s both
sides so it never showed up there, but the alias table's lookup is an
exact match against a column that's always stored lowercased — so the
fallback silently never matched a realistic (uppercase) identifier like
ENG-12, even though the alias row itself was written correctly.
Found while adding a test for the new project-space reconciliation flow
that exercises the same alias path via a batch move.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
* fix(comments): guarantee at-least-once processing of user comments (MUL-4195)
Consecutive comments on an issue were silently dropped: a new comment that
arrived while the agent already had a queued/dispatched task was discarded by
the HasPendingTaskForIssueAndAgent dedup, losing the user's follow-up
instruction with no visible trace. Comments — unlike chat — are deliberate,
addressed, persisted input and must never vanish.
This makes comment handling at-least-once while keeping concurrency bounded to
one run per (issue, agent):
- Merge, don't drop (PR1): a comment landing while a not-yet-started task
exists is folded into that task — the prior trigger becomes a coalesced
comment and the new one becomes the trigger, so a single run still covers
every deliberate comment. Falls back to a fresh enqueue if the pending task
was claimed mid-flight, so nothing is lost in the race.
- Completion reconciliation (PR2): on task completion, a member comment newer
than the run's started_at schedules exactly one follow-up via the normal
trigger pipeline. Loop-safe: member-authored only, capped by the existing
per-(issue,agent) dedup, and terminating.
- Visibility (PR3): coalesced_comment_ids is surfaced on the task API and in
the run prompt so the covered comments are explicit.
Migration 145 adds agent_task_queue.coalesced_comment_ids UUID[].
Tests: merge-not-drop preserves all three of a rapid burst and repoints the
trigger to the newest; reconciliation query gates on member/since; e2e
CompleteTask enqueues a follow-up for a mid-run member comment and does not for
none.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): address review — originator gate, agent-scoped reconcile, cross-thread coalesced prompt (MUL-4195)
Resolves GPT-Boy's Request-changes review on PR #5068.
Must-fix #1 — merge no longer inherits a stale originator/runtime context.
MergeCommentIntoPendingTask now only folds a comment into a pending task
whose originator_user_id IS NOT DISTINCT FROM the new comment's originator.
runtime_mcp_overlay / runtime_connected_apps are a pure function of
(originator, agent) and the agent is fixed, so a matching originator keeps
the stored overlay/attribution valid; a differing originator (e.g. user B
commenting on a task originated by user A) matches no row and the caller
enqueues a fresh follow-up with B's own context instead of reusing A's.
trigger_summary is refreshed to the new trigger comment.
Must-fix #2 — completion reconcile no longer re-wakes unrelated agents.
reconcileCommentsOnCompletion computes the latest member comment's triggers
and keeps ONLY the agent that just completed, instead of fanning the comment
out through the full pipeline. An @-mention of agent B during agent A's run
is triggered once at creation time and is no longer replayed (double-run)
when A completes.
Should-fix #3 — coalesced-comment prompt no longer assumes a single thread.
The claim response now carries each folded comment's thread id / author /
created_at / content (CoalescedCommentData); the prompt embeds them directly
so the agent addresses cross-thread folded comments without the wrong
"they are in the triggering thread" hint. Old servers that ship only ids
fall back to an issue-wide fetch, still without the same-thread assumption.
Tests: TestMergeCommentIntoPendingTask_OriginatorGate (query gate),
TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun (reconcile
scoping), TestBuildCommentPromptCoalescedCrossThread / IDsOnlyFallback
(prompt). Existing MUL-4195 suites still pass.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): close unique-index drop + dispatched-window race in comment coalescing (MUL-4195)
Second-round review follow-up on PR #5068.
Must-fix #1 — originator-mismatch no longer drops the comment.
The previous originator gate returned ErrNoRows on a different originator and
the caller fell through to a fresh enqueue, which collided with the
idx_one_pending_task_per_issue_agent unique index (one queued/dispatched task
per (issue, agent)) — silently dropping the second user's comment. Replaced
the gate with recompute-on-merge: MergeCommentIntoPendingTask now re-stamps
originator_user_id, runtime_mcp_overlay, runtime_connected_apps and
trigger_summary to the new comment's originator. A different member's comment
folds into the single coalescing run carrying the latest instruction's own
identity/overlay (no cross-user capability bleed, no drop, no collision).
Must-fix #2 — comment arriving in the claim→StartTask window is no longer lost.
Merge now targets only PRE-CLAIM states ('queued','deferred'); a
dispatched/running task is never a merge target, so a post-claim comment is
never falsely stamped into coalesced_comment_ids as "delivered". Completion
reconcile is re-anchored on dispatched_at (the moment the claim response is
built) instead of started_at, and sweeps ALL undelivered member comments since
that anchor — replaying each through the normal enqueue path so they coalesce
into one bounded, agent-scoped follow-up run. This covers the dispatch→start
window a started_at anchor missed.
Enqueue path: on a merge miss the caller no longer blindly fresh-enqueues
(which could collide with a dispatched sibling); it defers to the active
task's completion reconcile via HasActiveTaskForIssueAndAgent, and only
fresh-enqueues when no active task exists.
Tests: rewrote the query test to
TestMergeCommentIntoPendingTask_RecomputesOriginatorAndSkipsDispatched;
added TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath (full handler
enqueue path, two distinct originators) and
TestCompleteTask_ReconcilesDispatchedWindowComment (claim→start window). All
existing MUL-4195 handler/cmd-server/daemon/service suites still pass.
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): catch pre-dispatch merge-race comment in completion reconcile (MUL-4195)
Third-round review follow-up on PR #5068.
Race: a member comment is created while the task is still queued, but its
merge loses the race to the daemon claiming the task (queued→dispatched). The
merge then finds no pre-claim row (ErrNoRows), the enqueue path defers to
reconcile — but the comment's created_at is BEFORE dispatched_at, so the
dispatched_at-anchored reconcile skipped it and the comment vanished with no
task coverage.
Fix: anchor completion reconcile on the task's created_at (which always
precedes dispatch) instead of a dispatch/start timestamp, and exclude the
run's DELIVERED SET — trigger_comment_id ∪ coalesced_comment_ids. Because
merges only ever touch pre-claim rows, that set is exactly what the claim
response carried, so any member comment created since the task was made that
is NOT in it was genuinely undelivered and earns a bounded follow-up. This
catches the pre-dispatch merge-race comment and the dispatch→start comment,
while never re-firing a comment that was delivered as a pre-claim coalesced
entry.
Test: TestCompleteTask_ReconcilesPreDispatchMergeRaceComment reproduces the
race (comment created pre-dispatch, task dispatched before merge, plus a
delivered coalesced comment) and asserts exactly one follow-up, triggered by
the race comment, with the delivered coalesced comment excluded. Existing
reconcile fixtures updated to set a realistic created_at (the production
invariant that created_at is the earliest task timestamp).
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): merge only into the queued task, never a deferred fallback (MUL-4195)
Fourth-round review follow-up on PR #5068.
MergeCommentIntoPendingTask targeted status IN ('queued','deferred') ordered
by created_at DESC. When a (issue, agent) pair had both an older queued task
(the run about to be claimed) and a newer deferred assignee-fallback task, a
new comment merged into the deferred row instead of the queued one — so the
comment missed the imminent run and the deferred fallback could later promote
into a duplicate/conflicting run.
This merge is only ever reached when HasPendingTaskForIssueAndAgent matched a
queued/dispatched task (it never inspects deferred), so the coalescing target
must be the queued row. Restricted the merge target to status = 'queued'
(the unique index guarantees at most one). Deferred fallbacks keep their own
fire_at/promotion escalation lifecycle and are never a merge target.
Test: TestMergeCommentIntoPendingTask_TargetsQueuedNotDeferred seeds an older
queued task + a newer deferred fallback for the same (issue, agent), merges a
new comment, and asserts it lands on the queued task (trigger repointed, old
trigger coalesced) while the deferred fallback is left untouched.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Integrate the official openai-go SDK (v3) as a thin, reusable LLM layer
(pkg/llm) backing lightweight utility calls that do not need the agent
runtime (chat titles, quick-create drafts, ...).
Expose two user-authenticated, OpenAI-compatible chat-completions
endpoints:
- POST /api/llm/v1/chat/completions (JSON response)
- POST /api/llm/v1/chat/completions/stream (SSE stream)
Requests decode directly into the SDK's ChatCompletionNewParams and
responses are relayed via RawJSON() for byte-exact OpenAI-format
compatibility. Base URL and API key are configurable (MULTICA_LLM_*),
and the model is taken from the request with a configurable default
fallback (MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini). When unconfigured
the endpoints return 503.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The multica target ran `go run` with no -ldflags, unlike `build`, so
main.version stayed at its hardcoded "dev" default for any daemon
started via `make daemon`. The quick-create CLI version gate treats
"dev" as unparsable (fails both the semver check and the git-describe
dev-build exemption), so Create with agent blocked with "doesn't
report a CLI version" for any locally dev-run daemon.
MUL-4261 surfaced cancelled issues only when the status filter explicitly
selected "cancelled": a separate BOARD_STATUSES (six statuses, cancelled
excluded) plus a runtime showCancelled gate hid cancelled from the default
list/board/swimlane. That is the wrong product model — cancelled is a
lifecycle state in the same category as todo/in_progress/done/blocked and
should be a first-class default column.
- Remove BOARD_STATUSES. Its only purpose was to exclude cancelled, which
this change reverses. PAGINATED_STATUSES is now ALL_STATUSES; the surface's
default visible/hidden status derivation, the assignee-grouped board's
default status set, and the swimlane column fallback all use ALL_STATUSES.
- Remove the `bucketedIssues.filter(status !== "cancelled")` gate in the
surface data layer. Cancelled flows through to list/board/swimlane columns,
header facet counts, batch selection, and isEmpty like every other status.
- hiddenStatuses derives from ALL_STATUSES, so cancelled participates in the
board show/hide controls consistently (hideStatus already used ALL_STATUSES).
The status filter now narrows the visible set instead of unlocking an
otherwise-hidden bucket. Cancelled renders last (its canonical ALL_STATUSES
position). Mobile keeps its own status mirror and is out of scope.
Regression tests updated: controller now asserts cancelled is a default
visible status, the filter narrows (and can hide cancelled), swimlane renders
the Cancelled column by default and drops it only when the filter narrows past
it, and the assignee board fetches cancelled by default.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): surface cancelled issues via status filter (MUL-4261)
Cancelled issues were never visible in the web/desktop issue surface:
`PAGINATED_STATUSES`/`BOARD_STATUSES` excluded `cancelled`, so the list/
board/swimlane never fetched or rendered it, and the status filter offered
a "Cancelled" checkbox that resolved to an empty list.
Implement plan A (fetch-always, hide-by-default):
- `PAGINATED_STATUSES` now includes `cancelled`, so it is always fetched
into the byStatus cache and rebuckets correctly when an issue is
cancelled (previously the card was dropped). `BOARD_STATUSES` stays the
default *visible* column set.
- The surface gates the flattened list on the status filter: cancelled
issues are excluded from `surfaceIssues` (and therefore list/board/
swimlane columns, header facet counts, batch selection, and isEmpty)
unless the filter explicitly selects "cancelled". Then a Cancelled
section appears, sorted last.
- `hiddenStatuses` stays board-only, so cancelled is never offered as a
hideable/persistent board column.
Dragging a card into the Cancelled column (visible only when filtered)
sets status=cancelled through the existing generic column DnD — no new
entry point or copy added.
Non-goals (unchanged): mobile, member/agent archive surfaces, an
always-on cancelled column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): swimlane must keep the cancelled column when filtered (MUL-4261)
The swimlane derived its status columns as
`BOARD_STATUSES.filter(s => visibleStatuses.includes(s))`, re-imposing
canonical order by intersecting with BOARD_STATUSES. Since BOARD_STATUSES
omits `cancelled`, a filter-selected Cancelled column was silently dropped
even though the controller's `visibleStatuses` included it — the surface
fetched and gated cancelled correctly, but swimlane never rendered it.
Filter against ALL_STATUSES instead: same canonical ordering, but a
selected `cancelled` column now survives. `hiddenStatuses` stays
board-only, so cancelled is still never a hideable/persistent column.
Regression tests:
- swimlane renders a Cancelled column + its cards when cancelled is in
visibleStatuses, and omits it otherwise (verified failing pre-fix);
- controller asserts hiddenStatuses never contains cancelled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): autolink read-only URLs in the parse tree, not raw text
Read-only markdown surfaces (comments, descriptions, chat) pre-linkified
bare URLs by rewriting the raw source to [url](url) before parsing. Because
linkify-it treats `*` as a valid URL character, a bare URL followed by a
bold close — `**PR:https://…/5081**` — had the trailing `**` swallowed into
the match and rewritten as [url**](url**). That consumed the emphasis closer
(the bold never closed; the leading `**` rendered as literal asterisks) and
corrupted the href with a trailing `**` (MUL-4242).
Let remark-gfm autolink URLs in the parse tree instead, where emphasis is
already resolved so an adjacent delimiter can never be absorbed. The custom
string pass now runs in a `urls: false` mode on read-only surfaces and only
linkifies file paths (which gfm never does). A small remark plugin
(remark-cjk-autolink) re-applies the existing CJK URL boundary to gfm's
autolink literals so `https://x/a。后面` still stops at 。.
The Tiptap editor path is unchanged (`urls: true`): @tiptap/markdown does not
autolink bare URLs, so it still needs the string pass.
Note: read-only URL autolinking now follows GFM semantics (scheme, www., or
email required); bare fuzzy domains like `NBA.com` render as plain text on
read-only surfaces, matching CommonMark/GFM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): keep every URL in a CJK-separated run linked in readonly
Follow-up to the read-only autolink fix. remark-gfm glues `url1、url2` into a
single autolink literal because it treats CJK punctuation as a URL character;
remark-cjk-autolink trimmed only at the first terminator and dropped the tail
to plain text, so the second URL stopped being a link — a same-class regression
of MUL-4242 for CJK-punctuation-separated URLs (flagged in review).
Re-derive the segments with detectLinks (which reuses collectLinkifyMatches'
truncate-and-rescan) and rebuild the [link, text, link, …] sequence, so every
URL in the run stays linked. Adds a read-only test for
`两个地址 https://a.com/x、https://b.com/y`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(core): add exponential backoff with jitter to WSClient reconnect
The WebSocket client used a flat 3-second reconnect delay with no
backoff, jitter, or attempt limit. When the server restarts, every
connected client (web + desktop) reconnects at exactly T+3s, creating
a thundering-herd connection spike.
Replace the fixed delay with exponential backoff:
- Base delay 1 s, doubling each attempt (1 → 2 → 4 → 8 → …)
- Cap at 30 s to keep recovery time reasonable
- ±20 % jitter to decorrelate clients that disconnect simultaneously
- Give up after 20 consecutive failures (log error, allow manual retry)
- Reset the counter on successful authentication
Add 7 unit tests covering the backoff curve, cap, jitter range,
counter reset, max-attempt cutoff, and disconnect cancellation.
Closes#5035
* fix(core): clamp jittered delay to max and make jitter test deterministic
Address Copilot review feedback:
1. Clamp the final delay to RECONNECT_MAX_DELAY_MS after jitter is
applied. Previously, when base was already at the 30s cap, +20%
jitter could push the delay to 36s, violating the configured max.
2. Replace the nondeterministic jitter test (which relied on real
Math.random() producing ≥2 distinct values in 20 samples) with a
deterministic stub that alternates between 0 and 1, asserting
exact min/max delays (800ms and 1200ms).
* fix(core): remove reconnect attempt limit, retry indefinitely with capped backoff
Address maintainer feedback (NevilleQingNY):
The web/desktop UI does not currently expose a visible disconnected
state or manual retry action, so the 20-attempt give-up limit would
leave an open tab silently stale after a long outage. Remove the
limit and let the client retry indefinitely with the 30s capped
jittered delay.
- Drop RECONNECT_MAX_ATTEMPTS constant and the give-up early-return
- Update JSDoc to document the indefinite-retry contract
- Replace "stops after max attempts" test with "keeps retrying
indefinitely with capped delay" that verifies 25+ attempts still
schedule reconnects at 30s
Chat list avatars read too large at 36px; inbox list was 28px, so the two
surfaces were inconsistent. Standardize both to 32px (the common list-row
avatar size) — chat list 36->32 (fallback placeholder size-9->size-8),
inbox list 28->32.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The agent profile hover card now surfaces the runtime-native model id
(mono, e.g. `claude-opus-4-8`) with the reasoning/effort token as a badge,
so a quick hover answers "which model is this agent running?" without
opening the detail page. Empty model renders a "Runtime default" placeholder.
Also fixes the Skills row: chips wrap to multiple lines, so the vertically
centered label drifted to the middle chip — it now pins to the first chip
row (items-start + pt), and each chip truncates so a long skill name can't
blow out the card width.
The effort badge is gated on `effort` alone (not `hasModel`): an agent with
no pinned model can still persist a thinking_level override that applies at
run time, and hiding it would misreport the agent's real config. Adds
agent-profile-card.test.tsx covering the model/effort render states,
including the `model:"" / thinking_level:"high"` regression.
New i18n: profile_card.model_label / model_unset (en/zh-Hans/ja/ko).
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): advance selection to next chat when archiving the open session
Archiving the chat currently open in the two-pane Chat tab left the
conversation pane showing a now read-only, dangling session. Mirror the
Inbox list's handleArchive: move selection to the next chat in the
sorted, non-archived history list, fall back to the previous one, and
clear only when nothing is left. Archiving a non-open row is unchanged.
Closes MUL-4278
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): route archive-advance through the shared controller
Address review of #5110:
- Advance now routes through handleSelectSession so selectedAgentId stays
in sync when the next chat belongs to a different agent (a follow-up
"new chat" no longer defaults to the archived chat's agent).
- Move the advance/next-prev/clear logic into use-chat-controller
(advanceSelectionAfterArchive + archiveSession) and drive both Chat-tab
entry points from a single ChatPage.handleArchive: the thread-list row
AND the conversation header ⋯ menu (the header previously only flipped
status, stranding the user on the archived read-only conversation).
- Mobile: archiving the open fullscreen conversation returns to the list.
- Floating window: archiving the open chat now advances to the next chat
(with cross-agent sync) instead of clearing, matching the Chat tab.
Tests: controller test covers advance/fallback/clear/no-op + cross-agent
sync; thread-list test now asserts it delegates to onArchive.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Tiptap's Placeholder only reads its text at mount, and ContentEditor had a
defaultValue-sync effect but no placeholder-sync effect. Switching between
sessions of the same agent doesn't remount the editor, so the placeholder
froze on the previous value — e.g. stuck on "This session is archived" after
visiting an archived session, even on an active, usable input.
Mutating the extension's string option at runtime does not repaint (Tiptap
snapshots a string placeholder at mount). A function placeholder, however, is
re-invoked on every decoration pass, so:
- extensions: `placeholder` option now accepts `string | (() => string)`.
- content-editor: pass a getter over a live `placeholderRef`; a new sync effect
updates the ref and dispatches an empty transaction (docChanged=false, no
onUpdate loop) to force a decoration recompute — no remount required. This
also fixes placeholder staleness when the session/agent archive state changes.
- chat-input: update the editorKey comment (placeholder no longer relies on the
agent-switch remount).
- tests: getter reads the live value + one repaint on change; no repaint when
the placeholder prop is unchanged.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)
Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).
Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
session selection; web + desktop routing wired up. Removes the old
chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
unread *count* badge (read-cursor model), presence-gated typing vs waiting.
Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
empty state (avatar + name + description + starter prompts), and a
deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
(migration 146, dedicated chat_pinned_agent table + handler/queries).
New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
static template).
- Design: fade the global --border token; borderless list headers on
Chat/Inbox, kept (faded) on the conversation header.
Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)
The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.
Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.
- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)
* feat(chat): Settings toggle for the floating chat window (MUL-4235)
Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.
- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.
typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)
Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).
- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
revert the Preferences chat section and its test mock; store tests updated
for the opt-in default.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)
The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").
The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).
Based on the Chat V2 branch (parent MUL-4171, #5076), not main.
Co-authored-by: Lambda <lambda@multica.ai>
* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)
Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.
Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.
Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.
Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.
* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)
Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).
MUL-4253
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)
The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.
Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address review findings + unbreak CI (MUL-4171)
- task:failed now refreshes the sessions list (invalidateSessionLists), so
the thread-list preview / unread / sort stays correct after an agent
failure — FailTask persists a failure chat_message but only broadcasts
task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
?session= id isn't in it (deleted / no access / never existed) with nothing
in flight, clear the selection instead of rendering an editable empty chat
that would POST into a nonexistent session. Freshly-created sessions are
exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
expectedSegments) — keeps the two in sync, fixes the failing @multica/core
test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
EmptyState.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)
Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.
- Move the self-heal into the shared useChatController so every surface (tab
and floating window) drops a dangling activeSessionId once the sessions list
has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
or is a just-created session still awaiting the refetch; a dangling id falls
through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
(hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
— a session deleted elsewhere with real cached history stays eligible for
self-heal. Add a unit test for the discriminator.
Co-authored-by: multica-agent <github@multica.ai>
* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)
The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.
- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
unread_count, and hides the badge when all sessions are read — so this drift
is caught next time.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)
Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.
- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
original window renders (V2 had dropped them); drop the now-unused
window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
window, gated by floatingChatEnabled (default off) and hidden on /chat.
typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)
Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
chats; hard delete lives only there (hover -> unarchive + delete, with
the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
chats unarchive/delete.
Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)
* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)
Re-review follow-up on the restored floating ChatWindow + archive flow:
1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
shared controller, so its ensureSession trusted any non-empty
activeSessionId and there was no dangling-session cleanup — a deleted /
no-access persisted session could send into a nonexistent session. Ported
the same guard used for the tab: a self-heal effect that clears a dangling
activeSessionId once the sessions list has loaded, and ensureSession only
trusts an id that's in the list or has an in-flight optimistic write
(hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
the optimistic message + pending task before setActiveSession, so a
freshly-created session is never mis-cleared.
2. Floating dropdown bypassed archive-first safety. Its active rows offered a
hard-delete, letting the floating window destroy active chats and skip the
"archive first, delete only from Archived" model. Active rows now ARCHIVE
(reversible, one-click) like ChatThreadList; the floating window offers no
hard-delete — unarchive/delete live only in the full Chat page's Archived
view (reachable via expand). Removed the now-dead delete-confirm machinery.
3. Orphan user message on archived-agent send. SendChatMessage created the
chat_message before EnqueueChatTask, which rejects an archived / runtime-less
agent — a stale client would land a user message then get a 500, orphaning
it. Added a preflight that checks the session agent's archived / runtime
state and returns 409 before any mutation, plus a handler test asserting the
send is rejected with no message persisted.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Four pre-existing frontend test failures, unrelated to space-context work:
- app-sidebar.test.tsx: mock of @multica/ui/components/ui/sidebar was
missing SidebarGroupAction (added when the Spaces section landed),
so every test in the file crashed on render.
- use-issue-actions.test.tsx / welcome-after-onboarding.test.tsx: both
asserted the old /{ws}/issues/{id} route; the identifier-first
/{ws}/issue/{id} route replaced it. Updated the expected paths.
- issue-detail.test.tsx: same stale-route issue on the breadcrumb link,
plus a real bug in the IssueDetail wrapper found while fixing it —
the wrapper resolves a non-UUID issueId to a canonical UUID via
useQuery, and on resolve failure falls back to
<IssueDetailInner issueId={issueId}>, which re-subscribes to the
*same* query key. That fresh subscription's default refetchOnMount
refetches the query, flipping the wrapper's own `isError` back to
false mid-render, which unmounts IssueDetailInner (canonicalId still
undefined) — which removes the subscription, which lets the retry
settle back to isError:true, remounting IssueDetailInner — forever.
In production this mostly self-heals fast; in tests with a
synchronously-rejecting mock it live-locked and no test could ever
observe a stable "not found" frame.
Fixed by latching the wrapper's fallback decision in a ref once
isError fires (a resolution failure shouldn't un-fail on a child's
incidental refetch), and by giving the wrapper its own loading state
(extracted IssueDetailSkeleton, shared with IssueDetailInner) instead
of rendering null while resolving — real UX gap where navigating via
a slow-to-resolve identifier showed a blank page instead of a skeleton.
packages/views test suite: 162 files / 1656 tests passing. Core/views/
web/desktop typecheck clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tab-bar.tsx now reads the current workspace and its space list to
render a space's own icon on space-scoped tabs, but tab-bar.test.tsx's
@multica/core/paths mock didn't export useCurrentWorkspace and nothing
mocked useQuery, so every test in the file threw at render time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Space-scoped tabs (/{slug}/space/{key}/...) now render the actual
space's icon via SpaceIcon instead of a generic route glyph, reading
from the same space list cache the sidebar uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AgentInvocationTarget.target_type's "team" is an unrelated, inert
placeholder from migration 130 (agent_invocation_permission) —
"reserved for the future team concept... NOT effective in V1" — not
the workspace Team entity this branch renamed to Space. The server's
CHECK constraint, Go constant, and packages/core/types/agent.ts all
still say "team"; only apps/mobile/data/schemas.ts had been swept to
"space" by mistake (most likely an overzealous find/replace during the
Team→Space rename), which desynced it from the server and broke
mobile's typecheck. Revert schemas.ts and its test back to "team" to
match the real server contract.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI was red across backend/frontend/mobile:
- backend: migration 132 made issue.space_id/autopilot.space_id NOT NULL,
but ~28 test files across internal/handler, cmd/server, and cmd/migrate
still raw-SQL-inserted issue/autopilot rows without space_id. Backfilled
every one via (SELECT id FROM workspace_space WHERE workspace_id = $N
LIMIT 1), reusing the existing workspace_id positional param — matches
the convention already established in daemon_test.go/comment_resolve_test.go.
Seeded a workspace_space row for every test that creates its own local
workspace (cross-workspace fixtures), mirroring setupHandlerTestFixture.
- frontend: scripts/generate-reserved-slugs.mjs had a stale hardcoded
`/create-team` example in its generated doc comment (never updated in
the team→space rename), so `pnpm generate:reserved-slugs`'s output
diverged from the committed reserved-slugs.ts and CI's drift-check failed.
- mobile: agent-schema.test.ts asserted the unknown-target_type fallback
was "team"; the schema's actual .catch() fallback is "space" — stale
assertion, not a schema bug.
go test ./... and go vet ./... are clean; pnpm generate:reserved-slugs
produces no diff; mobile's agent-schema test passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`issue reorder` takes exactly one target: --top, --bottom, --before, or
--after. That rule was a hand-rolled runtime count in runIssueReorder; declare
it with cobra's MarkFlagsMutuallyExclusive + MarkFlagsOneRequired (extracted
into registerIssueReorderFlags, shared with the tests) so cobra validates it
before RunE with canonical messages and shell completion drops the sibling
target flags once one is set.
Keep an explicit guard for no-op target values that cobra's presence check
cannot see: empty --before/--after, and --top=false / --bottom=false.
Follow-up to #4110; addresses the second review item from the merge comment
(the first was handled in #5072).
Co-authored-by: Nick Webster <nick@nitrad.co.uk>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#5057 restricted version derivation to `git describe --tags --match 'v[0-9]*'`,
but the command was passed to `execSync` as a shell string. On Windows the
shell is cmd.exe, which does not strip the POSIX single quotes around
'v[0-9]*', so git received the quotes literally, matched no tag, fell through
to `--always`, and the version degraded to the `0.0.0-g<hash>` fallback.
That is what shipped a `0.0.0-gc05b67ae4` Windows Desktop build (electron-builder
`--publish always` then auto-created a bogus release) during the v0.3.41 release,
even though the tag was sitting exactly on HEAD. Linux/macOS were unaffected
because /bin/sh strips the quotes.
Fix: invoke git with an argv array via execFileSync in every version-derivation
path, so the match pattern reaches git as one literal argument regardless of
platform:
- apps/desktop/scripts/package.mjs (Desktop version → electron-builder)
- apps/desktop/scripts/bundle-cli.mjs (bundled CLI ldflags version)
- apps/desktop/src/main/app-version.ts (dev-mode version fallback)
The Makefile is intentionally left as-is: make's `$(shell ...)` always runs via
/bin/sh (even on Windows) and the CLI release runs on Linux, so its single
quotes are stripped correctly.
Tests: export `deriveVersion` and `DESCRIBE_ARGS` and add coverage that runs the
real `git describe` against throwaway repos (clean semver tag, semver tag chosen
over a nearer non-semver tag, and the no-tag fallback), plus a structural check
that the match pattern is a bare argv token with no embedded quotes. The prior
suite only unit-tested the `normalizeGitVersion` string transform, which is why
this slipped through.
MUL-4256
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
- CLI/agent runtime brief: thread Space (id/key/name) through the daemon
claim → TaskContextForEnv → prompt pipeline; add "## Space Context" and
--space to the issue create/update command syntax in both the slim and
legacy brief renderers, so agents see Space the same way they see Project
- Replace the create-space modal with a routed page (/space/new, mirrors
SpaceDetailPage's layout; member picker is the same popover-checkbox
pattern as the edit page)
- Reserve "NEW" as a space key (client + server) so /space/new can never be
shadowed by a real space's /space/:key detail page
- packages/core/spaces: last-used-space memory (useLastSpaceStore) feeding
resolveCreationSpaceId, shared by issue/quick-create/project/autopilot
creation surfaces
- zh-Hans: translate Space → 空间 across locales and conventions.zh.mdx
(Notion/语雀/飞书文档 use the same term; avoids the 团队/工作区 collisions
the prior "keep English" rule was written to dodge)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(runtimes): make rename a machine action + fix picker search copy (MUL-4217)
Follow-up to the runtime-naming feature based on testing feedback:
1. The create-agent runtime picker filters by MACHINE (its search matches the
machine title / host / provider names), but the placeholder said "Search
runtimes", which misled users into typing a runtime name. Change the
placeholder and the empty-state to "Search machines" / "No matching
machines" (all 4 locales).
2. Rename was framed as "rename this runtime" with an opt-in "apply to whole
machine" checkbox, but the intent is naming the machine (the computer), not
an individual runtime. Rework it into a machine-level action:
- New RenameMachineDialog always names the whole machine (apply_to_machine);
the per-runtime dialog + checkbox are gone.
- The entry now lives on the Runtimes page, on the selected machine's header
(a pencil next to the machine title), owner/admin gated — that's where the
user looked for it. Removed the per-runtime pencil from the runtime detail
page.
No backend change — apply_to_machine and machine-name inheritance already exist.
Verified: pnpm typecheck (all apps), full views suite (1650) + locale parity,
lint (0 errors).
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): always show machine header in picker; pre-fill rename with shared name only
Testing + review follow-ups on #5087:
1. The create-agent picker hid the machine group header when there was only
one machine (e.g. after a search narrowed to one), collapsing to a flat
list. Always render the machine header so grouping stays consistent.
2. (Elon) RenameMachineDialog pre-filled from the first non-empty custom_name
on the machine, but the machine title only uses a name when ALL runtimes
share one (sharedCustomName). A lone per-runtime name would thus pre-fill as
if it were the machine name — the same runtime-vs-machine confusion this PR
set out to remove. Export sharedCustomName and use it for the pre-fill;
otherwise pre-fill empty. Added direct unit tests.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Conflict resolution:
- ListAutopilots (autopilot.sql / autopilot.sql.go): keep branch's
space_id filter alongside main's default archived-exclusion
(#5042 changed delete-autopilot to archive, and made the list
default to hiding archived unless status is explicitly requested)
* feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213)
Scan-to-bind was authorized by workspace role only, so a non-admin
member could not bind a Lark bot even to an agent they own. Authorize
the device-flow install, status poll, and revoke by the same rule that
governs every other agent-management op — canManageAgent: the agent's
owner OR a workspace owner/admin.
Backend:
- router: begin/status/revoke drop to workspace-member level; the
per-agent check moves into the handlers (agent_id is a query param /
installation id, which the role middleware can't see).
- BeginLarkInstall + RevokeLarkInstallation load the target agent and
run canManageAgent.
- GetLarkInstallStatus scopes the read to the session initiator or a
workspace owner/admin; others get 404 (no existence leak). Session
state now carries InitiatorID for this.
Frontend:
- LarkAgentBindButton takes agentOwnerId and lets the agent owner
through (mirrors canEditAgent).
- Agent Integrations tab gates Lark per-agent (owner or admin) while
Slack stays workspace-admin-only, since its routes are unchanged.
Tests: begin/status/revoke authorization (owner, agent owner, unrelated
member) on the backend; agent-owner bind visibility on the frontend.
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): keep orphan installation revoke available to workspace admins (MUL-4213)
RevokeLarkInstallation loaded the bound agent and ran canManageAgent
unconditionally, so once the agent was hard-deleted the load 404'd and
a workspace owner/admin could no longer disconnect the orphan Lark
installation — a documented cleanup path (ListByWorkspace lists orphans;
the active-connection query filters them; Settings surfaces "Unknown
Agent" Disconnect).
Fall back to workspace owner/admin-only revoke when GetAgentInWorkspace
finds no agent; agents that still exist keep the owner-OR-admin
canManageAgent check. A plain member gains no orphan-row cleanup rights.
No FK/cascade — resolved in the application layer.
Adds a backend regression test: orphan installation is revocable by a
workspace owner but not a plain member.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(lark): isolate topic-group sessions by thread
A Feishu topic group (话题群) collapsed every topic into one
chat_session: the session binder passed the raw group chat id as the
engine BindingKey, violating the engine.EnsureSessionInput contract
that a threaded platform must never key sessions by raw chat id.
Multiple users @-mentioning the bot in different topics shared one
transcript, and replies all landed in whichever topic wrote
last_thread_id last.
Adopt the Slack channel:threadRoot model: a message inside a topic
(thread_id present) keys the session by "chat:thread" and persists the
real chat id in the binding config (larkBindingConfig); outbound paths
(chat reply, error card) resolve the send target via outboundChatID —
config first, falling back to the key for pre-topic rows, which keeps
legacy bindings routing unchanged. P2p and plain (non-topic) group
chats keep the raw chat id key and existing behavior.
No migration: existing topic-group sessions stay as-is; new topic
messages create per-topic sessions from the first @-mention onward.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(lark): remove unused CreateLarkChatSessionBinding helper
The helper and its CreateChatSessionBindingParams had no callers; all chat-session bindings are created through the shared engine.EnsureSession path. Dropping the dead code removes a way to bypass the engine and create topic bindings with a hardcoded empty config.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Non-semver tags (e.g. release-train tags) could become the nearest match
for `git describe --tags`, producing a version string that is not a valid
semver prefix. Restrict describe to `v[0-9]*` tags across the CLI ldflags,
desktop bundling, and app-version paths so the resolved version always has
a `major.minor.patch` shape.
* feat(runtimes): custom runtime names + searchable machine-grouped picker
MUL-4217. Runtime names were daemon-generated ("Claude (host)") and
uneditable, so picking one at agent-create time was painful once a
workspace had many machines.
Phase 1 — create-agent RuntimePicker: add a search box (>6 runtimes) and
group options by machine (Local/Remote/Cloud, online-first, current
machine first) reusing buildRuntimeMachines/filterRuntimeMachines. Rows
show the provider under a machine header instead of a flat repeated list.
Phase 2 — custom names: new nullable agent_runtime.custom_name column,
never written by the registration/heartbeat upsert so the daemon can't
clobber it; display is coalesce(custom_name, name) via runtimeDisplayName.
PATCH /api/runtimes/:id gains custom_name (+ apply_to_machine to name every
runtime sharing a daemon_id in one action, owner-scoped for non-admins).
Rename UI on the runtime detail page; `multica runtime rename` CLI command.
Verified: go build/vet, sqlc, handler tests (incl. new custom-name single
+ machine-fanout), 1650 views + 764 core TS tests, typecheck, locale parity.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): address review — persist machine name on new registrations, keep custom_name in register response
Elon's review on #5070 (MUL-4217):
1. Machine name looked "lost" when a new provider registered on an
already-named machine — the new row landed with custom_name=null and
broke sharedCustomName. Now a fresh runtime inherits the machine's shared
custom name at register time (ListDaemonCustomNames + sharedDaemonCustomName),
so the machine title stays stable as providers come and go.
2. DaemonRegister rebuilt the response row by hand and dropped custom_name,
so register returned custom_name:null — inconsistent with list/get/update.
Both branches now carry CustomName.
Also: tighten the updateRuntime patch type to custom_name?: string (drop the
misleading `| null`, since the server treats null as "unchanged", not "clear").
Tests: register response preserves custom_name; new runtime inherits machine name.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): inherit machine name for failed-profile registrations too
Elon's re-review of #5070 (MUL-4217): the machine-name inheritance added
last round only covered the normal req.Runtimes path. The req.FailedProfiles
branch also upserts a daemon_id-scoped agent_runtime row (offline, profile
registration error), which shows up in the runtime list / machine grouping —
so on a named machine a failed custom-profile row landed with custom_name=NULL
and dragged the machine title back to the hostname.
Extract the inheritance into h.inheritMachineCustomName and call it from both
the normal runtime path and the failed-profile path. Add a test: named daemon
+ failed profile upsert -> the failed row's persisted custom_name is inherited.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Squad create/manage was gated behind workspace owner/admin, inconsistent with
agents and projects which any member can create. Move squads to a creator-scoped
model: any member can create a squad and becomes its creator, and manages only
the squads they created; owner/admin continue to manage every squad.
Backend (server/internal/handler/squad.go):
- Add canManageSquad (admin/owner OR creator) and gate UpdateSquad, DeleteSquad,
AddSquadMember, RemoveSquadMember, UpdateSquadMemberRole on it (member load +
squad load + per-squad check, replacing requireWorkspaceRole).
- CreateSquad is now member-creatable.
- Add memberCanWireAgent: a non-admin may only wire agents they can @-trigger
(canInvokeAgent as themselves) as squad leader (create/update) or worker
(add member); admins may wire any workspace agent. Prevents a creator from
smuggling an agent they cannot invoke into a squad.
Frontend:
- squad-detail-page: compute per-squad canManage (admin || creator) and render
the inspector, members tab, instructions and archive read-only otherwise,
mirroring the agent detail canEdit pattern.
- squads-page: per-row actions and the actions column now key off per-squad
canManage instead of workspace-admin.
Squads stay visible workspace-wide (ListSquads unfiltered); creator transfer is
out of scope for this iteration.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
position (the manual board order) is always sorted ascending server-side,
so --direction was silently dropped for the default/position sort. A passed
-but-ignored flag is a footgun, especially in scripts. Reject the combination
up front with a message that names the directional sort columns instead.
MUL-4222
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The coordinator's absent-card status-move branch shifted one unit of
server total between the two status buckets and stopped there — no
stale key. Under the app's staleTime: Infinity + no focus-refetch
setup, explicit invalidation is the only channel that reconciles a
loaded list, so an open board would show the moved count with the row
permanently missing from the destination bucket's visible window
(e.g. "done 61" with 60 visible rows) until an unrelated event
happened to invalidate the list.
Push the list key onto staleKeys after a successful moveBucketTotal.
The count still moves instantly (optimistic UX unchanged); the flush
timing follows the existing contract — mutations invalidate on
onSettled, the WS path immediately. No mutation/WS fork, no new
coordinator parameters (MUL-4182).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>