This is the structural prep PR for the runtime-brief diet roadmap on
MUL-3560 — split out from the per-section compression work so the
refactor risk and the content-gating risk land separately.
Output is byte-for-byte identical to the pre-refactor monolithic
builder for every existing test fixture. All existing tests pass
unchanged. The follow-up PR (0.6) will start gating sections per the
Section × Kind matrix from Eve's design comment on MUL-3560 (drop
Mentions / Comment Formatting / Issue Metadata / Sub-issue out of
quick-create, drop Comment Formatting / Mentions / Attachments out of
autopilot and chat, etc.), with negative assertions added alongside
each removal so each kind's brief contract becomes machine-checked.
What changed:
- runtime_config_kind.go (new): taskKind enum + classifyTask helper.
Five kinds, mutually exclusive in practice; the documented
precedence rule keeps the tiebreak deterministic even if a future
caller breaks the mutex by accident. hasIssueContext is the
predicate Issue Metadata and Sub-issue Creation gate on.
- runtime_config_sections.go (new): every section of the brief is
extracted into its own writeXxx helper, preserving byte sequences
exactly. Helpers with internal conditions keep those conditions;
helpers the dispatcher always wants to call are unconditional.
- runtime_config.go: buildMetaSkillContent collapses from a
~450-line monolith to a ~70-line dispatcher — prelude, conditional
context sections, a 5-way kind switch over Workflow bodies, and
trailing sections. The matrix of "which kind gets which section"
is now readable in one screen.
- runtime_config_kind_test.go (new): TestClassifyTask covers all 5
kinds plus tiebreak cases; TestTaskKindHasIssueContext pins the
predicate semantics; TestBuildMetaSkillContentKindMatrix is the
structural canary that locks today's per-kind section set so any
later PR that drops a section from a kind must update its
expectations in lockstep.
Verification:
- go build ./... ok
- go vet ./internal/daemon/... ok
- go test ./internal/daemon/... ./internal/handler/... ok
- go test ./internal/daemon/execenv -run TestClassifyTask -v pass
- go test ./internal/daemon/execenv -run TestTaskKindHasIssueContext pass
- go test ./internal/daemon/execenv -run TestBuildMetaSkillContent pass
No production behaviour change in this PR. No new logging, no new
char-bucket fields — those are in PR #4439 and compose cleanly with
this refactor.
Co-authored-by: multica-agent <github@multica.ai>
`GET /api/issues/:id/usage` endpoint (handler `GetIssueUsage`). Returns the
aggregated token usage for an issue, summed across all of its task runs.
The usage is already captured server-side and shown in the issue detail view,
but was not reachable from the CLI, so it couldn't feed billing/cost scripts.
This closes that gap with no backend changes.
$ multica issue usage MUL-139
INPUT_TOKENS OUTPUT_TOKENS CACHE_READ CACHE_WRITE RUNS
5625 83880 9190806 154078 1
$ multica issue usage MUL-139 --output json
{ "total_input_tokens": 5625, ... "task_count": 1 }
Accepts an issue key (`MUL-139`) or UUID, mirroring `issue runs`.
- Table cells use the existing `formatMetadataValue` helper so large cache-token
counts render as plain integers (not scientific notation).
- Scope is the per-issue aggregate the endpoint already returns (`task_count` =
run count); a per-run token breakdown is out of scope.
- `go test ./cmd/multica/ -run TestRunIssueUsage` (added) ✅
- `go vet ./cmd/multica/` ✅
- Verified against a live self-hosted server; numbers match the issue UI.
- `server/cmd/multica/cmd_issue.go` — command + handler
- `server/cmd/multica/cmd_issue_test.go` — unit test
- `CLI_AND_DAEMON.md` — docs
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint
Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.
Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.
Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): inject handoff note into assigned runs via first-class task field
Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).
Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.
Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): leave a display-only handoff record on the timeline
When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.
Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)
Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): exclude handoff records from new-comment counting
type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.
Test: a handoff record does not bump the new-comment count; a real comment does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)
Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
keys; locale parity green.
Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): use a valid anchor in the handoff count-exclusion test
CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(core): preview schema malformed/missing/null fallback coverage
Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): remove display-only handoff timeline record (留痕)
The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.
- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
migration 123 (comment_type_check reverts to the 4-type set from 001);
no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
issue_trigger_preview_test.go (note-injection tests retained)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): dismissable run-confirm modal + team-handoff copy
Two fixes to the pre-trigger confirm modal (MUL-3375).
1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
shadcn Dialog so it has the close (X) button + Esc + click-outside.
Previously the only choices were "start" / "don't start now" with no
way to abort the action entirely; dismissing now cancels with no write.
2. Copy: rework the action-surface wording away from the backend term
"run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
record surfaces). Unifies the note's three names to "交接说明", and
parallels the rewrite across en/ja/ko.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* chore(agent): bump handoff note min CLI version to 0.3.28
The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(issues): skip run-confirm when batch-moving issues to backlog
A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(issues): refine pre-trigger preview hint and copy
- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
property toolbar. It was sharing the footer button row and, lacking a
width constraint, reflowed the submit buttons whenever it appeared.
Restyle to a borderless, comment-style avatar+caption that is purely a
caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
evaluates and delegates rather than "starting work" itself. Add
will_start_named_squad / will_start_squad / create_will_start_squad across
en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(issues): stub ActorAvatar in create-issue suite
CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop kanban card snapping back on drag
A cross-column drag on a non-position-sorted board left the card in its
origin column for the whole request, then jumped to the target only when
the mutation settled — the "snaps back, then moves" glitch. Root cause was
three coupled choices in the optimistic path:
- board-view never updated local columns on drop for sortBy != "position"
(onDragOver is a no-op there), so the card relied on the settle refetch
to move across.
- useUpdateIssue invalidated the whole list on settle, replacing the column
and re-landing the card even on success.
- patchIssueInBuckets appended a moved card to the column tail instead of
its position slot, so any later cache refresh teleported it to the end.
Fixes:
- board-view: optimistically move the card into the target column on drop
for the non-position path (insertIdByPosition), and reconcile local
columns from the cache on settle for both paths (revert on error now that
the list is no longer refetched).
- mutations: reconcile via onSuccess surgical patch of the returned entity;
drop the list/detail invalidation from onSettled (aggregates still flush).
- cache-helpers: patchIssueInBuckets inserts the moved/reordered card at its
position slot; a plain field update still keeps its slot.
Adds cache-helpers and drag-utils unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): patch My-Issues / Project board caches on move too
The drag fix made the board reconcile local columns from its feeding cache
on settle. The workspace board rides issueKeys.list (patched by onMutate),
but the My-Issues and Project boards ride the myList cache, which the
mutation did not patch — so a successful move snapped back on those boards.
useUpdateIssue now patches/snapshots/rolls back every bucketed list cache
(workspace list + myList), selected by the ListIssuesCache `byStatus` shape
so grouped (assignee) and flat (gantt) caches are skipped. Adds renderHook
regression tests covering both-cache optimistic move, both-cache rollback,
and no-list-invalidation-on-settle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): drop redundant WS position->list invalidate
onIssueUpdated already surgically patches the non-filtered workspace board
via patchIssueInBuckets (cross-status move + same-column reorder). The extra
`if (position) invalidateQueries(list)` re-pulled the whole board on top of
that, re-introducing drag flicker through the echoed-back WS event. Removed.
Filtered myAll lists still invalidate (membership can change there) — the
client-side membership reconciliation for those is a separate follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): batch update patches myList + stops list refetch on settle
- onMutate now patches both issueKeys.list and the filtered issueKeys.myAll
bucketed caches, so a batch edit on a My-Issues / Project board is
optimistic too. Previously only the workspace board was patched, so batch
edits on those boards relied entirely on the settle refetch.
- onSettled no longer invalidates issueKeys.list: the optimistic patch is a
complete reconcile for these bucketed boards (batch changes status /
priority / project, never a server-computed value), so a full-board
refetch only re-introduced the flicker the single-issue path removed.
Aggregate / grouped caches still refresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): list view optimistically moves row on non-position drag
The sortBy != "position" branch called onMoveIssue without moving the row in
local columns, so the row sat in its origin group for the whole request and
only jumped across on settle -- the same snap-back the board view had before
its fix. Now mirrors board-view: setColumns(insertIdByPosition) on drop so
the settle rebuild is a visual no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): keep My-Issues/Project boards in place on non-membership WS change
onIssueUpdated now surgically patches the filtered myList (myAll) caches and
only invalidates them when the change can actually move an issue in/out of the
filter: an assignee change (covers My-Issues direct-assignee + the involves leg
+ actor panels) or a project change (Project board). A pure status / position /
priority / label change reconciles in place -- no refetch -- removing the last
drag flicker on filtered boards.
Uses the assignee_changed flag the server already sends on issue:updated
(surfaced on IssueUpdatedPayload + forwarded by the realtime dispatch); project
change is diffed client-side against the cached value. No predicate replication,
no backend change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): add settle-lock to swimlane drag (no clobber mid-flight)
The swimlane drag had no settle window: the resync useEffect (and the issueMap
freeze) guarded only isDraggingRef, so a cache change landing after drop but
before the move settled could rebuild localCells out from under the optimistic
move. Adds isSettlingRef + settleVersion (mirroring board-view / list-view): the
lock is held from drop until onMoveIssue settles, then released, forcing a
single resync from the reconciled cache.
onMoveIssue now accepts the same optional onSettled callback board/list already
use; the parent handleMoveIssue supplies it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): extract shared useDragSettle hook for board + list
board-view and list-view carried byte-identical drag/settle scaffolding (the
local columns mirror, the dragging/settling locks, the post-move animation-frame
throttle, and the settle callback). That duplication is exactly what let
list-view silently drift earlier (it had lost the optimistic-move half of the
fix, and its position-branch settle callback omitted the settleVersion bump).
Extract the primitive into useDragSettle so both surfaces share one
implementation and can't drift again.
Behavior-preserving for board-view. For list-view the one intended alignment:
its position-branch failed move now reverts, gaining the settleVersion bump
board-view already had.
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>
Agents creating sub-issues only saw the runtime brief's Sub-issue
Creation section, which taught the manual todo/backlog serial chain and
never mentioned stages — the `--stage` flow was documented only in the
multica-working-on-issues skill, which an agent reads only if it opens
it. So agents defaulted to hand-managed backlog chains and rarely reached
for stages.
- Add an "Ordering with stages" paragraph to the brief's Sub-issue
Creation section nudging agents to group ordered/waiting sub-issues
with --stage instead of hand-promoting a backlog chain.
- List --stage on the brief's issue create / update command lines and
add multica issue children to the Core command list for discoverability.
- Extend the brief test with the new stage assertions.
The Sub-issue Creation section stays gated to issue-bound runs (skipped
for chat/quick-create/autopilot), unconditional on parent_issue_id, and
free of parent-notification guidance — all existing canaries still pass.
MUL-3508
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Replace the # (Hash) icon for the Stage property with the Milestone icon
across the picker trigger, dropdown option rows, and the Add-property menu.
Shrink the Stage dropdown option font to text-xs (scoped to the Stage
picker only; the shared PickerItem keeps text-sm so other property
dropdowns are unaffected).
* feat(issues): stage sub-issues so the parent wakes per stage, not per child
Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.
Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.
- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)
Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): address review on stage barrier (cancel, batch, unstaged)
Resolves the three blockers from the PR review:
1. Cancel can close a stage. The child-done barrier now fires on any
non-terminal -> terminal transition (done OR cancelled), not just done.
isTerminalChildStatus already treats cancelled as terminal (a cancelled
sibling never finishes, so it must not hold a stage open), so a cancelled
last-open child now closes its stage and wakes the parent. Keying on the
transition also makes a later cancelled -> done edit a no-op, avoiding a
lagging duplicate wake.
2. Batch update of stage no longer no-ops. `hasMutation` now includes
"stage", so `{"updates":{"stage":N}}` persists instead of returning
{"updated": 0}.
3. Unstaged children no longer participate in the staged frontier. In a
staged sibling set, NULL-stage children neither hold a stage open nor fire
on their own completion, and the wake comment no longer renders "Stage 0".
This matches migration 123 ("NULL does not participate in staged
grouping") and the CLI's separate unstaged group, removing the footgun
where an unstaged backlog child silently blocked Stage 1.
Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage
Frontend for the sub-issue stage feature (web + desktop, shared via packages):
- core: `stage` on the Issue type + create/update request types; zod
IssueSchema parses it (defaults to null for older backends) with schema
tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).
Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): add stage to Issue fixtures merged from main
The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable
The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.
- Compute the sibling max stage at both call sites: the create modal now
loads the parent's children (childIssuesOptions) and the detail sidebar
reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
selectable and offers Stage 6).
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Fix GitHub pull_request and check_suite webhook routing so events are attributed to the workspace that registered the repository, with fallback to the installation workspace. Includes host-qualified repo matching, account-gated registry routing, deterministic matching, and regression coverage.
* feat(daemon): inject project description into the agent brief
Issues bound to a project only surfaced the project title in the runtime
brief; the project description (durable, project-wide context the owner
sets) was loaded but dropped. Carry it end-to-end:
- claim handler reads proj.Description onto the response (issue-bound and
quick-create paths)
- new ProjectDescription field on AgentTaskResponse, daemon Task, and
TaskContextForEnv
- rendered in the brief's `## Project Context` section and written to
.multica/project/resources.json as project_description
Empty descriptions render nothing (no extra heading). Updated the
projects-and-resources built-in skill docs in the same change.
MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
* feat(projects): clarify project description is injected as agent context
The project description is now durable context injected into every task's
brief, but the UI still presented it as a plain "Description" field, so
existing descriptions could silently become agent input. Add a hint under
the description editor on the project detail page and in the create-project
modal, in all four locales, stating it is shared with agents as context for
every task in the project. No data-semantics change.
Addresses review feedback on PR #4395. MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): assert project description flows through task claim
The execenv tests cover brief rendering, but nothing pinned the claim
handler boundary where proj.Description is read onto the response. Add
two tests — issue-bound and quick-create paths — so a regression in that
assignment fails loudly instead of silently dropping the description.
Addresses review feedback on PR #4395. MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage
- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)
Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(agent): harden Qoder ACP drain and wire project skills path
- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(qoder): add provider logo and wire MCP config into ACP sessions
- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
config (object map) into ACP array format for session/new and
session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
empty/nil, and multi-server cases
Co-authored-by: Orca <help@stably.ai>
* fix(test): capture both return values from InjectRuntimeConfig in Qoder test
Co-authored-by: Orca <help@stably.ai>
* fix(qoder): preserve remote MCP headers and promote provider errors
Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.
1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
only forwarded url/type, so an authenticated remote MCP server looked
configured in Multica but failed inside the Qoder session. Replace it
with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
use), which preserves headers as [{name, value}], sorts for
deterministic output, and handles remote transport aliases. Fail
closed on malformed mcp_config instead of silently dropping servers.
2. Provider failures could report as completed tasks. stderr was wired
via io.MultiWriter and the result was only promoted to failed when
output was empty, so a terminal upstream error (HTTP 429 / expired
token) racing a stopReason=end_turn with text still became
"completed". Switch to StderrPipe + an explicit copier, drain it
(bounded by the existing grace window, since qodercli can leave a
child holding the inherited fds) before the decision, and run the
shared promoteACPResultOnProviderError.
Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.
Co-authored-by: Orca <help@stably.ai>
* fix(qoder): align ACP session handling
Co-authored-by: Orca <help@stably.ai>
* fix(agent): guard qoder late output after drain
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The batch action toolbar hardcoded status="todo", priority="none", and a
null assignee, so the status/priority/assignee pickers always checked a
fixed row regardless of the selected issues. The batch write itself worked,
but the picker mis-reported the current value, surfacing as "status always
defaults to todo" (MUL-3510). The same defect applied to priority and
assignee, across all five toolbar mount points.
Derive the shared status/priority/assignee of the selected issues via a new
commonIssueFields helper and feed it to the pickers; when the selection is
mixed, pass an empty value so no row is checked. Pickers now accept a
nullable current value, and AssigneePicker gains a `mixed` flag to
distinguish an all-unassigned selection (check "No assignee") from a mixed
one (check nothing). Each call site passes its issue universe, mirroring the
skill list's selected-rows approach.
Adds unit tests for commonIssueFields and a toolbar picker-wiring test.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Extend the clean target beyond server binaries/temp files to also
remove Next.js/source/Expo/Electron build outputs, Turbo caches, and
tsbuildinfo files across apps and packages.
The header 'agent is working' chip previously required a click to reveal the
activity card. Open it on hover instead so the live signal reads as a
glanceable status surface. The hover config lives on Base UI's Popover.Trigger
(openOnHover + delay/closeDelay), and the trigger stays a real button so
click/keyboard access is retained for touch and a11y.
Add a regression test asserting openOnHover is wired on the trigger so a
click-only implementation can no longer pass.
MUL-3507
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Add a Join Discord promo card pinned to the bottom of the left sidebar
(above the help launcher). Dismiss state persists per-user in
localStorage so it stays hidden once closed.
Extract the shared DiscordIcon + invite URL into layout/discord.tsx so
the help launcher and the card reuse one source. i18n copy added for
en / zh-Hans / ja / ko.
MUL-3505
Co-authored-by: multica-agent <github@multica.ai>
Add a Discord invite (https://discord.gg/W8gYBn226t) in three places:
- Website footer: social icon + link in the Resources group (en/zh/ja/ko)
- In-app help menu: Discord item in the help launcher (all 4 locales)
- GitHub repo README: badge + link (README.md and README.zh-CN.md)
MUL-3492
Co-authored-by: multica-agent <github@multica.ai>
When a sub-issue transitions to done, notifyParentOfChildDone posts a
system comment on the parent and wakes the parent's assignee. A parent
deliberately parked in backlog should stay inert: waking it lets the
assignee promote sibling backlog sub-issues into todo, which is the
unwanted auto-activation reported in GitHub Discussion #4320 (MUL-3497).
Add a backlog guard alongside the existing done/cancelled guard so the
whole notification (comment + mention + trigger) is skipped until the
user explicitly moves the parent out of backlog.
MUL-3497
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The daemon auto-grants Codex item/permissions/requestApproval requests by
echoing back the network / fileSystem profile scoped to the current turn.
Previously a malformed params payload and any permission key outside
network / fileSystem were dropped silently, so a future app-server
protocol that adds a new permission shape would be narrowed away with no
trace in daemon logs.
Log both cases (parse failure and dropped keys) without changing the
granted response. Addresses review nits on #4346 / MUL-3451.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): reply inside the originating thread (话题) instead of the group
When a user @-mentions the bot inside a Lark topic/thread, the bot now
replies back into that thread rather than posting a fresh message at the
chat level. Behavior is automatic and scoped: only triggers that were
themselves inside a thread get a threaded reply, so normal group/p2p
chats are unchanged.
The outbound path is event-driven and decoupled from the inbound
message, so the trigger message_id + thread_id are persisted on
lark_chat_session_binding (migration 122) at ingest time. The patcher
then routes the agent reply (text / markdown card / error card) and the
OutcomeReplier notices (/issue confirmation, offline/archived) through
Lark's reply endpoint with reply_in_thread=true when a thread is present,
falling back to a chat-level send if the threaded reply fails.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(lark): classify thread-reply failures before chat-level fallback
Only retry a threaded reply at the chat level when Lark returns an
explicit "this message/topic cannot receive a threaded reply" error
(recalled trigger, topic gone, topics disabled, aggregated message,
etc.). Transport errors, 5xx, timeouts, rate limits, and ambiguous
failures are now logged and returned as failures instead of being
retried, so we never duplicate a reply or leak a thread-only reply
into the main group chat.
The three reply-capable send methods now return a structured *APIError
carrying the Lark business code, and isThreadReplyUnsupported drives
the fallback via an allowlist. sendWithThreadFallback is promoted to a
package-level function so the immediate OutcomeReplier sends (/issue
confirmation, offline/archived notices) share the same classified
fallback path instead of silently swallowing thread-reply failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: kun <kuen@micous.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Skill import builds raw.githubusercontent.com URLs by hand and fetches them
via fetchRawFile, which sent no Authorization header. GitHub API calls were
authenticated by #2215 (doGitHubAPIGet/addGitHubAuthHeader) but the raw
content download path was missed, so importing a skill from a private/internal
GitHub repo listed the directory fine and then 404'd on the actual file
download, surfacing as a generic 502.
Attach the existing GITHUB_TOKEN bearer header in fetchRawFile, but only when
the URL host is raw.githubusercontent.com. fetchRawFile is shared with
clawhub.ai / skills.sh downloads, so the token must not leak to those hosts.
The host gate is extracted into newRawFileRequest so it is unit-testable
without a live round-trip.
MUL-3496
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
When an issue is assigned to a squad, only the leader is triggered. The
leader briefing's Squad Roster listed each member's name, type, role, and
mention link — but not the member's assigned skills, so the leader had to
infer capability from the free-text role label when deciding who to
delegate to.
renderMemberRow now loads each agent member's assigned skills via
ListAgentSkillSummaries and formatRosterRow renders them as
"skills: a, b" (or "no skills assigned" when the agent has none). Builtin
multica-* skills are excluded (they live outside agent_skill); human
members carry no skills segment; a skill-lookup error degrades to the
prior name+role row rather than asserting a misleading "no skills".
Operating-protocol step 1 now tells the leader to match the task to each
member's listed skills.
Updates the multica-squads builtin skill and its source map to document
the new roster content, and adds
TestBuildSquadLeaderBriefing_MemberSkillsInRoster.
Co-authored-by: hal9000botagent <hal9000botagent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): autofocus OTP input on verification step
The email-verification step renders the OTP input without focus, so
users must click the field before typing the code. This is friction on
every login, especially when switching accounts.
Add `autoFocus` to the InputOTP so the cursor lands in the field as
soon as the step mounts. Mirrors the existing email-step input and the
mobile OTP component, both of which already autofocus.
* test(web): polyfill document.elementFromPoint for input-otp in jsdom
Autofocusing the OTP input makes input-otp run its focus-time DOM
measurement, which calls document.elementFromPoint. jsdom doesn't
implement it, so the web login test threw an unhandled error.
packages/views/test/setup.ts already stubs this for the same reason;
mirror the stub in the web test setup (which already stubs
ResizeObserver for input-otp).
* test(auth): assert OTP input autofocuses on verification step
Guards the autofocus behavior: the test fails if the autoFocus prop is
removed from the verification-step InputOTP. Lives in packages/views
since it covers shared component behavior, mocking @multica/core.
* feat(web): add opt-in react-grab dev element inspector
Loads the react-grab overlay (hold ⌘C / Ctrl+C + click to copy an
element's source path + component stack) only when REACT_GRAB is set in
a local, gitignored apps/web/.env.local. Both the NODE_ENV and REACT_GRAB
guards are evaluated server-side in the root layout, so the <Script> tag
is omitted from the HTML for anyone who hasn't opted in — no effect on
other developers or production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(desktop): add opt-in react-grab dev element inspector
Mirrors the web wiring for the Electron renderer: injects the react-grab
overlay (hold ⌘C / Ctrl+C + click to copy an element's source path +
component stack) only when VITE_REACT_GRAB is set in a local, gitignored
apps/desktop/.env.development.local. Guarded by import.meta.env.DEV so the
branch is tree-shaken out of production builds; never activates for other
developers. No CSP/sandbox blocks the unpkg script (webSecurity is off).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): unify react-grab opt-in var to VITE_REACT_GRAB
Use the same env var name as the desktop renderer so one variable name
controls both apps. The desktop renderer is bundled by Vite, which only
exposes VITE_-prefixed vars to client code, so the shared name must carry
the VITE_ prefix; web reads it server-side where the name is unconstrained.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
- expose `--callback-host` on `multica setup` and `multica setup cloud`, reusing the existing login callback override
- print an SSH tunnel hint when browser login runs in an SSH session with a loopback callback
- show local flags in parent-command help so `multica setup --help` documents the callback option
Fixes#4357
## Tests
- `go test ./cmd/multica -run 'TestCallbackHostFlagValueReadsParentSetupFlag|TestSetupHelpShowsCallbackHostFlag|TestSetupCallbackHostFlagWiring|TestBrowserLoginInstructionsSSHRemoteHint'`
- `go run ./cmd/multica setup --help`
- `go run ./cmd/multica setup cloud --help`
- `git diff --check`
- `go test ./...`
* fix(projects): require admin for project deletion
* test(projects): clean up orphaned member rows in delete-permission helper
The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): drop WS frames without a string type (MUL-3418)
The onmessage handler dispatched every parsed frame to onAny and the
ws.on subscribers without validating its shape. A frame whose parsed JSON
lacked a string `type` (an out-of-protocol frame injected by a proxy /
extension, or a bare JSON primitive) reached the realtime-sync onAny
dispatcher, where `msg.type.split(":")` threw an uncaught TypeError out of
onmessage. The browser reported it via window.onerror, flooding PostHog
`$exception` (~12k events). Non-fatal (no crash, connection stays up), but
pure noise.
Validate once at this trust boundary: a frame must be an object carrying a
string `type`, otherwise drop it as a no-op. The bad-frame log is
rate-limited to one entry per connection — a misbehaving source can repeat
the frame hundreds of times per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(analytics): drop benign ResizeObserver exceptions from telemetry
ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.
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>
* test: enable -race detector in Go test pipeline (WOR-61)
Add the -race flag to all three Go test invocation sites so the existing
concurrency regression harness (workdir_race_test.go for #3999,
runtime_gone_test.go, runtime_profile_drift_test.go) actually exercises
the race detector. The daemon package alone has 28+ goroutine launch
points with no automated race coverage before this change.
Sites updated:
- Makefile:299 (make test, local)
- .github/workflows/ci.yml:101 (CI backend job)
- .github/workflows/release.yml:55 (release verify job)
go test already runs a vet subset by default, so no separate -vet flag
is added. No production code touched.
Co-authored-by: multica-agent <github@multica.ai>
* test(execenv): serialize runtimeGOOS-mutating test (WOR-61)
TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged called
t.Parallel() while mutating the package-level runtimeGOOS to drive the
windows/linux branches, racing with the other parallel tests that read
runtimeGOOS in buildMetaSkillContent. The -race flag enabled in the
prior commit surfaced it as 3 WARNING: DATA RACE reports and 11
"race detected" failures in CI (only the execenv package failed).
Drop t.Parallel() and add the "// Not parallel: mutates the package-level
runtimeGOOS." comment already used by the six sibling writer tests across
execenv_test.go and reply_instructions_test.go. This is test-isolation
only; no production code, no mutex/atomic, no signature change.
Verified locally:
go test -race -count=1 ./internal/daemon/execenv/ -> ok 2.276s
go test -race -count=1 ./internal/daemon/... -> all 3 pkgs ok
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): discover local skills from ~/.agents/skills (MUL-3333)
Upgrade local skill discovery and import from a single provider root to an
ordered multi-root scan: the runtime's own skill directory (e.g.
~/.claude/skills) first, then the cross-tool universal root ~/.agents/skills.
- Rename localSkillRootForProvider -> localSkillRootsForProvider, returning
ordered roots [provider, universal] with a kind classifier.
- listRuntimeLocalSkills iterates the roots, gives each root its OWN visited
set (so a cross-root symlink alias is not collapsed), dedupes strictly by
Key with the provider root winning, and sorts once after the merge.
- loadRuntimeLocalSkillBundle walks the same priority order and only falls
through to the next root on os.IsNotExist; any other stat error is returned
so import never silently resolves a different same-key skill.
- Add a Root ("provider" | "universal") field to the local skill summary
(daemon + handler structs and the TS RuntimeLocalSkillSummary type) so the
UI can label a skill's origin without a future schema break.
Backward compatible: every skill visible today keeps its Key, SourcePath and
FileCount; the universal root only surfaces additional, non-conflicting skills.
Out of scope (follow-up issues): execution-time injection of ~/.agents/skills
into runtimes (e.g. Codex seedUserCodexSkills) and workspace-relative
.agents/skills discovery.
Tests cover universal-root discovery + import, provider-wins conflict
priority, both-roots merge, missing/both-missing roots, nested layouts,
IsNotExist fallback, the no-fallthrough-on-read-error guarantee, and the
per-root visited cross-root symlink alias. Docs updated in en/zh/ja/ko.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): fall through to next root when a same-key dir has no SKILL.md
loadRuntimeLocalSkillBundle previously only fell through to the next root on
os.IsNotExist for the skill DIRECTORY. A provider-root directory that shares a
skill's key but contains no SKILL.md (so listRuntimeLocalSkills descends past
it and surfaces the universal-root skill instead) made load stop on the
invalid provider dir and error — list and load disagreed, and the import the
user picked from the list could not be fetched.
Make the validity predicate match list: a root "has" the skill at a key only
when it is a directory containing a SKILL.md. A missing entry, a non-directory,
or a directory without a SKILL.md all mean "this root doesn't have it" and we
continue to the next root. Only a genuine non-IsNotExist stat error or an
unreadable existing SKILL.md (permission/IO) is returned, so we still never
silently substitute a different-content same-key skill from a lower-priority
root (Eve review #1, preserved by the existing read-error guard test).
Adds regression tests for the provider-dir-without-SKILL.md and provider-non-dir
fall-through cases.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
In context mode (chat) a query merges context items (current page / recently
viewed) with search results into one popup. The old contextLayout made only the
"Recent" group scrollable (`min-h-0`) while every other group was `shrink-0`, and
the Recent `<section>` did not clip its own overflow. When the search groups
(Users/Issues) filled the height, the flex algorithm squeezed Recent toward zero
and its un-clipped rows painted on top of the groups below — the overlap users saw.
Collapse the two render branches into a single `overflow-y-auto` flex column so
every group just stacks and the whole popup scrolls; no group can collapse onto
another. The context variant only differs in width / max-height / chrome.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(desktop): coerce commit-hash versions to valid semver
normalizeGitVersion checked only the first character (/^\d/) to tell a real
version from a bare commit hash. A hash beginning with a digit (e.g.
'2f24057b') passed that check and was stamped as the app version, but bare
'2f24057b' is not valid semver, so electron-updater threw on launch.
Require a full major.minor.patch prefix; anything else (including a
digit-leading hash) falls back to 0.0.0-<hash> as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(desktop): prefix bare-hash fallback with `g` for valid semver
normalizeGitVersion coerced an untagged build's bare commit hash into
`0.0.0-<hash>`, but that is not guaranteed valid semver: an all-digit
short hash with a leading zero (e.g. `0123456`) produced `0.0.0-0123456`,
and a numeric semver pre-release identifier must not have a leading zero.
electron-updater then threw on launch for exactly the untagged builds
this fallback exists to protect.
Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
shorthand) so the pre-release is always a single alphanumeric identifier.
Add a regression test for the all-digit leading-zero case.
Addresses PR #4183 review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
#4288 swapped the chip cap from a fixed `max-w-72` to `max-w-[min(18rem,100%)]`.
A percentage max-width on a flex item is dropped while its flex-container wrapper
(`<a class="inline-flex">`) computes its own max-content size, so the wrapper
ballooned to the untruncated title width while the chip truncated to the cap —
leaving an empty, clickable strip after the visible chip.
Fix it as standard atomic-inline behavior instead of a fixed magic cap:
- IssueChip caps at `max-w-full` with the title truncating to an ellipsis, so it
wraps to the next line as a unit and only truncates once a whole line can't
hold it.
- Drop `inline-flex` from the editor NodeView `<a>` (issue + project) and the
markdown AppLink so the chip's only wrapper is a plain inline box with a
definite (line-based) percentage basis — no second flex container to balloon.
ProjectChip uses a definite `max-w-72`, so it never hit the gap; left as-is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>