Commit Graph

435 Commits

Author SHA1 Message Date
Jiayuan Zhang
8d38396f59 fix(onboarding): clear session before skip_existing completion (MUL-2166)
Emacs review: handleWelcomeSkip ran after startOnboardingSession had
already fired during the shell's mount effect, so the skip_existing
onboarding_completed event was carrying the session id we documented
it would omit. Clear the session before completing so the event
matches the doc and HogQL IS NOT NULL filter isolates real funnel
completions cleanly.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:51:11 +08:00
Jiayuan Zhang
a033725506 feat(onboarding): correlate funnel events with onboarding_session_id (MUL-2166)
Issue funnel attribution: `onboarding_completed` previously joined to
`onboarding_started` on `distinct_id` alone, which collapsed
skip_existing / invite_accept completions into the same bucket as
real funnel completions. PostHog's 30-day backfill could only link
20 / 152 completions back to a start.

Generate an `onboarding_session_id` on `onboarding_started`, persist
it to client storage so it survives reloads, attach it as a property
on every onboarding event (client and server), and clear it on
`onboarding_completed`. Skip / invite paths never receive a session
id; HogQL funnels filter `onboarding_session_id IS NOT NULL` to
isolate real funnel completions from soft completions.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:43:59 +08:00
DimaS
efddb2284b fix(issues): clean caches after issue delete (#2487)
* fix(issues): clean caches after issue delete

* fix(issues): restore partial batch delete snapshots
2026-05-13 22:30:16 +08:00
Jiayuan Zhang
7e20ca27bb fix(issues): unify assignee menu with shared AssigneePicker (MUL-2157) (#2543)
* fix(issues): unify assignee menu with shared AssigneePicker (MUL-2157)

The Assignee submenu inside IssueActionsMenuItems was a parallel
implementation: no search, no squads, no agent permission check, no
archive filter, no frequency sort. The divergence was most visible from
the Inbox (where the issue detail's sidebar starts collapsed, so users
reach for the 3-dot menu).

Replace the submenu with a single menu item that closes the
surrounding dropdown / context menu and hands off to the shared
AssigneePicker popover — same component already used in the issue
detail sidebar, board cards, batch toolbar, and create-issue modal.

The picker is conditionally mounted to avoid every row in list / board
views subscribing to the members / agents / squads / frequency queries
on mount.

Co-authored-by: multica-agent <github@multica.ai>

* test(issues): mock squadListOptions + add Assignee picker handoff test

`AssigneePicker` reads `squadListOptions` and `assigneeFrequencyOptions`
from `@multica/core/workspace/queries`. Tests that render IssueDetail
or IssueActionsDropdown without those mocks throw at the picker's
useQuery call and cascade into unrelated assertion failures — this is
what was leaving the `@multica/views` test job red on the MUL-2157 PR.

Add the missing mocks. Add a regression test that clicks the Assignee
menu item and asserts the shared picker (search input + Members group)
takes over, so a future regression to the parallel-implementation bug
this PR fixes fails loudly instead of silently.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:11:18 +08:00
Bohan Jiang
4c1bef2e1f feat(usage): mirror Tokens metric toggle onto Usage page Daily chart (MUL-2148) (#2540)
* feat(usage): mirror Tokens metric toggle onto Usage page Daily chart (MUL-2148)

#2537 added the Cost/Tokens metric toggle to the Daily chart inside the
runtime-detail Usage section (packages/views/runtimes/components/
usage-section.tsx). The workspace-level Usage page at /{slug}/usage
imports the same DailyCostChart primitive but renders it from
dashboard-page.tsx without any toggle wrapper, so #2537 only landed on
half of the surface that says "Daily cost".

This PR mirrors the same pattern to dashboard-page.tsx so users see
the toggle wherever a "Daily" chart appears.

Changes
- `packages/views/dashboard/utils.ts`: new `aggregateDailyTokens` helper
  that folds DashboardUsageDaily[] into the same DailyTokenData[] shape
  the DailyTokensChart consumes (mirrors aggregateByDate's dailyTokens
  branch from the runtimes side, adapted to DashboardUsageDaily field
  names).
- `packages/views/dashboard/components/dashboard-page.tsx`: rename
  `DailyCostBlock` → `DailyTrendBlock`, add a Cost/Tokens Segmented
  next to the section title, switch chart and title based on the
  active metric, per-metric empty-state (so a workspace with unmapped
  pricing but recorded tokens still gets a real Tokens chart while
  the Cost view falls through to the empty-state — same convention as
  DailyTab in usage-section.tsx).
- usage.json (en + zh-Hans): split `daily.title` into `title_cost` +
  `title_tokens`, add `metric_cost` + `metric_tokens` toggle labels.

* feat(usage): default Daily chart to Tokens metric

Most users land on /{slug}/usage to gauge "how much agent work
happened" rather than "how much was spent." Tokens is the more
universally meaningful axis on first read (Cost depends on having
pricing mapped for every model and on whether the workspace has
unmaintained models). Cost stays one click away via the same toggle.

Also reorder the Segmented so Tokens sits first, matching the new
default.
2026-05-13 22:07:47 +08:00
Jiayuan Zhang
291c2c7898 feat(usage): reuse runtime timezone picker on the usage page (#2533) (#2546)
* feat(usage): add timezone picker to usage page (#2533)

Extracts the runtime detail page's timezone dropdown into a shared
TimezoneSelect at packages/views/common/timezone-select.tsx and reuses
it in the usage page header, immediately to the right of the 7d / 30d
/ 90d segmented control. Defaults to the browser-resolved zone with
the same "(browser)" suffix rendering as the runtime page.

The runtime-detail TimezoneEditor still owns the PATCH mutation; only
the dropdown UI moved. UI-only — no API client / handler changes.

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): make header wrap so timezone picker fits on narrow widths

The h-12 PageHeader is a single non-wrapping flex row. Adding the
timezone picker with a 180px min-width pushed the title + project
filter + range switch + tz select past the viewport on narrow and
medium widths. Drop the picker's hard min-width, let the header grow
vertically (h-auto + min-h-12) and let the right toolbar wrap. Wide
viewports still render the original single row.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 15:58:53 +02:00
Jiayuan Zhang
9ad5eb5ffe fix(tests): add squad mocks to unblock views test suite (MUL-2158) (#2544) 2026-05-13 13:51:24 +02:00
Bohan Jiang
87464f6c03 fix(squads): i18n the Squad pages to unblock views#lint (CI red on main) (#2542)
#2505 (Squad MVP) merged with 29 hardcoded English strings in JSX text
nodes — packages/views/squads/components/squads-page.tsx (4) and
squad-detail-page.tsx (25). The package's eslint config enforces
`i18next/no-literal-string` as ERROR for every .tsx file, so
@multica/views#lint has been red on main, which Turbo cascades to
@multica/web#build, @multica/desktop#build, and @multica/views#typecheck
— effectively blocking every open PR's frontend CI (#2538, #2540, etc.).

Rather than disabling the rule for the Squad files (which would just
hide debt in a high-visibility surface), wire up a proper i18n
namespace and replace every flagged literal.

Namespace plumbing
- New `packages/views/locales/en/squads.json` and
  `packages/views/locales/zh-Hans/squads.json` covering all 29 flagged
  strings, grouped by surface (page / inspector / name_editor /
  add_member_dialog / description_dialog / discard_changes_dialog /
  members_tab / instructions_tab).
- Registered in `packages/views/locales/index.ts` and
  `packages/views/i18n/resources-types.ts` so `t($ => $.squads.*)` is
  type-safe.

Component replacements
- `squads-page.tsx`: add `useT("squads")`, replace 4 literals.
- `squad-detail-page.tsx`: add `useT("squads")` to seven inner
  components that hold flagged text (`SquadDetailPage` / `InlineEdit
  Popover` / `AddMemberDialog` / `RoleEditor` / `SquadDescriptionEditor`
  / `SquadDescriptionEditorBody` / `SquadOverviewPane` / `SquadMembers
  Tab` / `SquadInstructionsTab` / `SquadDetailInspector`), replace all
  flagged literals.
- Plural members count uses i18next's standard `_one` / `_other`
  suffixes via `t(..., { count })` — matches the convention already
  used in `runtimes/usage` and `agents`.

Notes
- A few unflagged user-facing strings remain (tab labels in
  squadDetailTabs array, ternary alternatives like `"Save"` inside
  `{x ? <Loader/> : "Save"}`, the inline `confirm()` archive prompt,
  the `toast.success("Leader updated")` message). The eslint rule
  uses `mode: "jsx-text-only"` so it only flags string children of
  JSX nodes; attribute strings, object-literal values, and ternary
  alternatives slip past. Those are real i18n gaps too but expanding
  scope here would gold-plate the CI-unblock fix.

Verification
- `pnpm --filter @multica/views lint`: 0 errors (was 29). Remaining 13
  warnings are pre-existing in unrelated files and don't fail CI.
- `pnpm typecheck`: 6/6 packages pass — namespace types resolve, all
  selector calls infer correctly.
2026-05-13 19:35:31 +08:00
Naiyuan Qing
cde3867d3b feat(sidebar): top/bottom scroll fade mask (MUL-2150) (#2536)
* feat(sidebar): top/bottom scroll fade mask (MUL-2150)

Apply useScrollFade to SidebarContent so the menu list softly fades
into the header / footer when overflowing, matching the existing
pattern used in chat list and onboarding steps.

Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): useScrollFade re-evaluates on content mutations

ResizeObserver only fires on the observed element's own box. When a
flex / auto-height container's children grow asynchronously (sidebar
pinned items loading from TanStack Query, collapsibles expanding),
scrollHeight changes but clientHeight does not — mask stayed 'none'
until the user scrolled. Add a MutationObserver on childList to
recompute fade when content is inserted or removed.

Co-authored-by: multica-agent <github@multica.ai>

* test(paths): include squads in workspace route consistency check

main added the squads parameterless route to paths.workspace() in #2505
but the C4 consistency assertion wasn't updated, turning frontend CI
red on every PR. Add 'squads' to both the parameterless-method set and
the segment-mapping table.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 19:02:08 +08:00
Bohan Jiang
c6ccc49650 feat(runtimes): add Tokens metric toggle to Usage Daily chart (MUL-2148) (#2537)
The runtime Usage page's Daily timeline only showed daily $ cost, which
hides the underlying usage shape: cost varies wildly by model price, so
a quiet day on Opus can outspend a busy day on Haiku. Add a Cost/Tokens
toggle next to the Daily/Hourly/Heatmap tabs that swaps the chart over
to a four-segment stack of raw token counts (input / output / cache
read / cache write).

No backend changes needed — the existing /api/runtimes/{id}/usage
response already carries the per-day per-model token breakdown; this
just wires up DailyTokensChart on top of the dailyTokens aggregate that
aggregateByDate was already producing.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 18:51:26 +08:00
LinYushen
29082f7cfe feat: implement Squad feature MVP (#2505)
* feat: implement Squad feature MVP

- Add migration 084_squad: squad, squad_member, squad_activity_log tables
- Extend issue.assignee_type to support 'squad'
- Add sqlc queries for squad CRUD, member management, activity logs
- Add Go handler with full Squad API (CRUD, members, activity log)
- Register routes: /api/squads/*, /api/issues/{id}/squad-activity, /api/squad-activity
- Add Squad trigger logic:
  - Assign Squad immediately triggers leader
  - Every external comment on squad-assigned issue triggers leader
  - Anti-loop: squad members' comments don't trigger leader
  - Dedup: skip if leader already has pending task
- Add squad activity log API (方案 B) for leader no-op recording
- Add frontend TypeScript types (Squad, SquadMember, SquadActivityLog)
- Add protocol events: squad:created, squad:updated, squad:deleted

Co-authored-by: multica-agent <github@multica.ai>

* fix: address PR review blocking issues

1. validateAssigneePair now accepts 'squad' assignee_type
2. All squad endpoints validate workspace ownership via GetSquadInWorkspace
3. CreateSquadActivityLog restricted to squad leader agent only
4. AddSquadMember validates member exists in workspace
5. UpdateSquad auto-adds new leader to squad members
6. DeleteSquad transfers assigned issues to leader before deletion
7. IssueAssigneeType includes 'squad' in frontend types

Co-authored-by: multica-agent <github@multica.ai>

* feat: soft-delete squads via archive instead of hard delete

- Add migration 085: archived_at + archived_by columns on squad table
- ListSquads now excludes archived squads (ListAllSquads for admin)
- DeleteSquad → ArchiveSquad (sets archived_at, preserves all records)
- Transfer squad-assigned issues to leader before archiving
- SquadResponse includes archived_at/archived_by fields
- Frontend Squad type updated with nullable archived fields

Co-authored-by: multica-agent <github@multica.ai>

* feat: re-add Squads frontend entry (sidebar nav + pages)

Re-applies the frontend squad entry that was lost during a merge:
- Sidebar nav: Squads item with Users icon
- Paths: squads() and squadDetail() in workspace paths
- Routes: /squads and /squads/[id] pages
- Views: SquadsPage (list) and SquadDetailPage
- i18n: en 'Squads' / zh '小队'
- Reserved slug: 'squads'

Co-authored-by: multica-agent <github@multica.ai>

* fix: fix SquadsPage rendering - use PageHeader children pattern

PageHeader takes children, not title/actions props. The incorrect
usage caused a React rendering error. Now matches the pattern used
by autopilots and agents pages.

Co-authored-by: multica-agent <github@multica.ai>

* fix(squads): add API client methods and package export for squads pages

* feat: complete Squad frontend - create dialog, member management, API methods

- Add CreateSquadModal with name/description/leader selection
- Register 'create-squad' in modal registry
- Wire 'New Squad' button to open the modal
- Add full API client methods: createSquad, updateSquad, deleteSquad,
  addSquadMember, removeSquadMember
- Rewrite SquadDetailPage with:
  - Member list showing resolved names
  - Add/remove member UI
  - Archive squad button
  - Back navigation to squads list

Co-authored-by: multica-agent <github@multica.ai>

* feat: improve Squad UI - match create agent dialog style

- CreateSquadModal: proper Dialog with Header/Description/Footer,
  agent picker with avatars, textarea for description
- SquadDetailPage: centered max-w-2xl layout, ActorAvatar for members,
  Crown badge for leader, textarea for member description,
  improved spacing and visual hierarchy
- Renamed 'role' field label to 'Description' in add member form
  (describes the member's responsibilities in the squad)

Co-authored-by: multica-agent <github@multica.ai>

* feat(squad): add avatar, instructions; drop unique-name constraint

- 086: add squad.avatar_url
- 087: drop unique constraint on squad.name (squads with the same
  name are legitimate across teams; uniqueness was an accidental
  product constraint)
- 088: add squad.instructions (text, default '')
- UpdateSquad now COALESCEs avatar_url + instructions
- handler exposes Instructions in SquadResponse and accepts it in
  UpdateSquad

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): assignable + mention target; trigger leader on assign

- assignee picker and @mention suggestion list squads alongside
  agents and members; renders squad avatar/icon
- creating or updating an issue with assignee_type=squad enqueues
  a task for the squad's current leader (mirrors agent-assignee
  parking-lot rule: skip backlog only)
- workspace queries/hooks expose squads where needed for the
  pickers
- locales updated for new picker copy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): agent-style detail page with members + instructions tabs

- restructure squad detail page to mirror the agent detail page:
  320px inspector (creator, leader, created/updated) + tabbed
  pane (Members | Instructions) with dirty-guard AlertDialog
- inline name + avatar editing on the inspector
- inline description editor (modal textarea)
- members tab: leader + member picker with role descriptions,
  swap leader, edit member roles, remove
- instructions tab: ContentEditor + Save (mirrors agent pattern)
- squads list shows the squad avatar/icon
- core types + api.updateSquad accept avatar_url + instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): inject leader briefing on claim (protocol + roster + instructions)

When a squad's leader agent claims a task on a squad-assigned issue,
append a system-level briefing to the agent's Instructions composed of:

1. Squad Operating Protocol — hard-coded rules: leader is a
   coordinator, dispatch via @mention, stop after dispatching,
   resume on re-trigger, do not work outside the roster.
2. Squad Roster — leader self-row plus one row per non-archived
   member with a literal mention markdown string ([@Name](mention://
   agent|member/<UUID>)) the leader can paste verbatim. Round-trips
   through util.ParseMentions, enforced by a contract test.
3. Squad Instructions — the user-defined squad.instructions block,
   omitted entirely when empty so we do not leave a dangling heading.

Non-leader members claiming the same issue receive no briefing.

Tests cover: full squad with mixed agent/human members, lone leader,
archived agents skipped, empty user instructions, mention round-trip,
and the leader/non-leader claim-handler gate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(squad): tell leader not to restate issue context in dispatch comment

After observing leaders padding their delegation comments with full
re-summaries of the issue body and prior discussion, make the
Operating Protocol explicit:

- assignees on Multica already have the full issue (title,
  description, all comments, attachments) and workspace context;
- delegation comments should add only what cannot be inferred
  (who is picked, why, extra constraints), aim for two or three
  sentences;
- restating context is now an explicit hard rule violation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(squad): unify leader evaluation into activity_log, add CLI command

- Squad member comments now trigger leader (only leader self-excluded)
- Replace squad_activity_log with activity_log (action: squad_leader_evaluated)
- Add CLI: multica squad activity <issue-id> <outcome> --reason
- Add API: POST /api/issues/{id}/squad-evaluated
- Update squad operating protocol to require evaluation recording
- Remove squad_activity_log table from schema and generated code

* feat(cli): add squad list, get, member list commands

* fix(squad): address review findings (P1+P2)

P1 fixes:
- Add 'squads' to reserved_slugs.json (source of truth)
- Add 'create-squad' to ModalType union
- Remove unused leaderOpen/selectedLeader in create-squad modal
- Replace literal JSX strings with i18n selectors (en + zh-Hans)

P2 fixes:
- Add 'squad' to mention regex (MentionRe)
- Fix human member lookup in squad briefing (use GetUser directly)
- Add squads routes to desktop app
- Add squad:created/updated/deleted to WSEventType + invalidation
- Reject archived squads as issue assignees

* fix(squad): restore zh-Hans key, publish activity event, invalidate issues on archive

- Restore create_project.title in zh-Hans modals.json (dropped by prior edit)
- Publish activity:created WS event after squad leader evaluation
- Invalidate issue queries on squad:deleted (archive transfers assignees)
- Add creator info to squad list cards

* fix(squad): realtime sync, rerun support, leader validation

- Use workspaceKeys.squads prefix for detail/member queries (realtime invalidation)
- Publish squad:updated after add/remove/role-change member mutations
- Support rerun for squad-assigned issues (targets leader agent)
- Reject assignment to squads whose leader is archived

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 18:46:20 +08:00
Naiyuan Qing
623d29f276 feat(agents): one-click create from curated templates (Phase 1) (#2520)
* docs(agents): three-phase agent quick-create plan

Captures the full design for moving agent creation from manual form +
one-by-one skill attachment to a tiered experience:

- Phase 1 (this PR): one-click curated templates, AI-free.
- Phase 2 (next): AI-recommended skills via the existing quick-create
  task mechanism — no new server-side LLM dependency.
- Phase 3 (later): AI creates the whole agent end-to-end, composing
  Phase 2 with a new `multica agent create` CLI driver.

Documents the architectural decisions that keep all three phases on
existing infrastructure (no SSE, no server-side LLM SDK, no new WS
channels), the two soft blockers Phase 1 unlocks for later phases
(createSkillWithFiles TX composability + skill same-name dedupe), and
the scope decisions we explicitly opted out of (Anthropic plugin
marketplace, ClawHub UI affordances).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skills): harden import against invalid UTF-8 and binary files

PG rejects two byte patterns in a TEXT column. Both crashed real skill
imports we hit while assembling the template catalog:

- Embedded NUL (0x00) -> SQLSTATE 22021. Already stripped by
  sanitizeNullBytes, kept as-is.
- Other invalid UTF-8 (e.g. 0x91 — Windows-1252 smart quote in a skill
  whose author saved prose from Word). sanitizeNullBytes now also runs
  strings.ToValidUTF8 over the content so the second class no longer
  takes the whole import down.

For non-text payloads (images, fonts, archives, compiled binaries),
sanitization isn't the right fix — agents never read those as text,
and the bytes can't survive a TEXT column at all. addFile now skips
them by extension before the per-bundle cap counters tick, logging
the skip so an unexpected drop leaves a breadcrumb.

Function name kept for compatibility with the many call sites; both
behaviours are strict supersets of the original.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(skills): split createSkillWithFiles for tx composition + add workspace find-or-create query

Two soft blockers cleared so create-from-template (next commit) can
fold N skill creates and the agent + binding writes into one outer
transaction:

1. createSkillWithFiles used to Begin/Commit its own tx. Caller
   composition was impossible — N invocations meant N separate
   transactions and no atomicity over the whole materialise step.
   Pull the body into createSkillWithFilesInTx(ctx, qtx, input); the
   original function becomes a thin wrapper that manages its own tx
   for standalone callers. Existing call sites: zero behaviour change.

2. Add GetSkillByWorkspaceAndName sqlc query — workspace skill lookup
   by name, anchored to UNIQUE(workspace_id, name) from migration
   008. Lets the template materialiser implement find-or-create:
   reuse the workspace's existing skill row when a template
   references the same name, rather than crashing on the unique
   constraint or polluting the workspace with `<name>-2` clones.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agents): agent template catalog + create-from-template endpoint

Server-side foundation for Phase 1 of the quick-create roadmap (see
docs/agent-quick-create-plan.md). Adds:

- server/internal/agenttmpl/ — embed-loaded catalog of curated agent
  templates. Each template ships pre-written instructions plus a list
  of skill URLs that get materialised into the workspace at create
  time. Validation runs at startup (init() panics on a malformed
  template) so a bad JSON ships as a deploy-time defect, not a
  runtime 500. Slug must equal the filename basename so the URL
  router is mirror-symmetric with the file layout.

- 11 starter templates covering Engineering / Writing / Building /
  Testing (code-reviewer, frontend-builder, planner, docs-writer,
  one-pager, html-slides, full-stack-engineer, …).

- Three new endpoints, all behind RequireWorkspaceMember:
    GET  /api/agent-templates           — picker list (no instructions)
    GET  /api/agent-templates/:slug     — detail with instructions
    POST /api/agents/from-template      — materialise + create

  Create flow:
    1. Auth + runtime authorization happen BEFORE the GitHub fan-out
       so a 403 never wastes 20s of upstream fetches.
    2. Pre-flight dedupe by cached_name reuses workspace skills
       without an HTTP fetch — second create-from-the-same-template
       drops from 20s to <100ms.
    3. Parallel fetch (30s per-URL timeout) for the remaining skills.
    4. Single transaction: every skill insert, the agent insert, and
       the agent_skill bindings. On any upstream fetch failure the TX
       rolls back and the API returns 422 with `failed_urls` so the
       UI can name the bad source(s).
    5. extra_skill_ids (user-supplied additions) are verified through
       GetSkillInWorkspace per id before attach, so a malicious client
       can't graft a skill from another workspace via UUID guessing.

- multica agent create --from-template <slug> CLI flag dispatches to
  the new endpoint with a 60s ceiling, matching `multica skill import`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agents): one-click create-from-template UI

Frontend half of Phase 1. CreateAgentDialog becomes a state machine
spanning four steps:

  chooser          → Start blank / From template cards
  blank-form       → existing manual form (post-chooser)
  duplicate-form   → existing form pre-filled from a duplicated agent
  template-picker  → grid of templates, click navigates to detail
  template-detail  → instructions + skill list preview + one-click Use

Picking a template never lands on the form: name auto-deduped against
existingAgentNames, runtime = first usable one, visibility = private.
Refinement happens on the agent detail page if needed. Same rationale
the doc spells out — templates exist precisely to skip configuration.

New components, all collapsible-by-default so quick-create stays fast:
  - template-picker.tsx — categorised grid, lucide icons + semantic
    accent tokens resolved through static maps so Tailwind's JIT picks
    up every variant (dynamic class strings would silently miss).
  - template-detail.tsx — instructions preview, skill list with cached
    descriptions, Use CTA. Renders the failedURLs banner when a 422
    fires — the only step that can trigger that response.
  - instructions-editor.tsx — collapsed preview-card / expanded full
    ContentEditor.
  - skill-multi-select.tsx + skill-picker-list.tsx — shared multi-
    select surface, also adopted by the existing skill-add-dialog.
  - avatar-picker.tsx — agent avatar upload, mirrors the inspector's
    visual language.

Schema-defended client (CLAUDE.md → API Response Compatibility): the
three new endpoints are wired through parseWithFallback with lenient
zod schemas. Desktop builds outlive any given server — a future
field rename / wrapping must not white-screen older installs.
listAgentTemplates accepts both the current bare array and a future
{templates: [...]} envelope. Coverage: 7 new schema-test cases in
schema.test.ts (null body, missing skills/instructions, malformed
create response, envelope migration).

Catalog + detail go through TanStack Query with staleTime: Infinity —
workspace-independent static data, no per-mount refetch.

Other:
- skill-add-dialog becomes a true multi-select (Confirm button +
  checkbox list); attached skills are filtered out of the list.
- agents-page hands the freshly-created Agent back to the dialog so a
  follow-up setAgentSkills can attach the form-selected skills.
- agent-overview-pane drops the mx-auto/max-w-2xl frame on config-
  tab content; the wider dialog visual language reads better with
  tabs filling the column.
- Every new UI string lives in both en/agents.json and
  zh-Hans/agents.json under create_dialog.* / tab_body.skills.* —
  locales/parity.test.ts blocks drift in CI.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): align skill import test + drop next-only lint suppression

- TestFetchFromSkillsSh_ResolvesRootLevelSkillMd now expects assets/logo.png
  to be skipped; matches the new addFile binary-extension guard
  (6fafd86e). The .png is intentionally dropped so PG TEXT inserts don't
  hit SQLSTATE 22021.
- packages/views shares zero next/* deps, so the @next/next/no-img-element
  eslint plugin isn't loaded there. The eslint-disable directive
  referencing it produced a hard "rule not found" error in CI lint. Raw
  <img> is the right primitive in views; remove the disable comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(agents): wrap CreateAgentDialog tests in workspace/navigation providers

The dialog now calls useNavigation() and useWorkspacePaths(), both of
which throw outside their providers. The existing tests rendered the
dialog bare and tripped both new requirements:

- NavigationProvider — supply a stub adapter so push() works for the
  agent-detail redirect.
- WorkspaceSlugProvider — useWorkspacePaths() requires a slug.

The blank-vs-template chooser is now the default first step; the
existing tests target the runtime picker on the manual form, so the
helper auto-clicks "Start blank" when no template is passed
(duplicate-mode tests skip the chooser).

Manual afterEach(cleanup) + document.body wipe. Base UI's Dialog
portal renders into document.body and leaves focus-guard/inert wrapper
divs behind across tests, so the second test in the suite saw two
"All" / "My Runtime" matches and getByText failed. The wipe is local
to this file rather than the shared setup because it isn't a global
issue — only suites that open Base UI dialogs hit it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 18:26:04 +08:00
Naiyuan Qing
19c40c5d68 fix(ui): translate hardcoded English strings in shared ui package (#2526)
The four user-visible strings exposed by packages/ui rendered untranslated
on every page that used them:

- file-upload-button.tsx — "Attach file" aria-label/title
- sidebar.tsx — "Toggle Sidebar" sr-only label/aria-label/title
- pagination.tsx — "Go to previous/next page" aria-labels
- CodeBlock.tsx — "plain text" language fallback + "Copy code" aria-label/tooltip

Root cause: the package had no i18n hookup at all because the package
boundary rule forbids importing @multica/core. Replicating the pattern
five times would have been the same hack five times. Hooking up
react-i18next directly is the structurally clean fix — i18next is a
generic library, not business logic, and the upstream I18nextProvider
already exposes the instance via context.

To let packages/ui typecheck the selector form standalone (i.e. without
the views resource-types augmentation in scope), the augmentation is
split: views declares everything except the `ui` namespace on a new
global `I18nResources` interface, and packages/ui contributes the `ui`
slice via declaration merging in packages/ui/types/i18next.ts. Views'
resources-types side-effect-imports that file so both packages see the
merged shape during downstream typechecks.

Scope intentionally excludes:
- packages/ui/components/common/error-boundary.tsx — keeping its fallback
  in English so a render-time crash never depends on i18n being healthy.
- apps/desktop/src/renderer/src/components/update-notification.tsx —
  ships with the next desktop release, not via this PR.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 18:25:40 +08:00
Naiyuan Qing
454c8e3d1a feat: in-app preview for non-image attachments (#2528)
* feat(storage): add GetReader to Storage interface

Adds a streaming read method to the Storage abstraction so callers can
pull object bytes without forcing a full in-memory load. S3Storage wraps
GetObject; LocalStorage opens the file with path-traversal and sidecar
guards. Tests cover happy path, traversal rejection, sidecar rejection,
and missing key.

Used in the next commit by the attachment-preview proxy endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server): add attachment preview proxy endpoint

GET /api/attachments/{id}/content streams the raw bytes of a
text-previewable attachment back to the client. Exists to (a) bypass
CloudFront CORS, which is not configured on the CDN, and (b) bypass
Content-Disposition: attachment which Chromium honors for iframe document
loads. Media types (image/video/audio/pdf) intentionally do NOT go through
this endpoint — clients render them directly from the signed CloudFront
download_url, which is already served with Content-Disposition: inline.

Hard cap: 2 MB. Larger files return 413. Anything outside the text
whitelist returns 415. The whitelist (isTextPreviewable) mirrors the
client-side dispatcher; the cross-reference comment in file.go flags
the manual sync until a JSON SSOT generator lands.

Response always uses Content-Type: text/plain; charset=utf-8 so a
hostile HTML payload can't be re-interpreted as a document. The
original MIME ships via X-Original-Content-Type for client dispatch.
Cache-Control: no-store so revoked attachment access takes effect
immediately on the next request.

Tests cover happy path (md), extension fallback when content_type is
generic, 415 (pdf), 413 (>2MB), foreign workspace (404 isolation), and
the isTextPreviewable table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(core/api): add getAttachmentTextContent + preview error types

Adds an ApiClient method that fetches the text body of an attachment via
the new /api/attachments/{id}/content proxy. Two typed errors —
PreviewTooLargeError (413) and PreviewUnsupportedError (415) — let the
preview modal render specific fallbacks instead of a generic failure.

Refactors the private fetch() into a shared fetchRaw() helper so the
new method inherits the standard infra: auth headers, 401 →
handleUnauthorized recovery, X-Request-ID, error logging, and the
ApiError contract. The previous draft bypassed all of these by calling
window.fetch directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(views/editor): add AttachmentPreviewModal + Eye entry points

In-app preview for non-image attachments. An Eye icon now sits next to
the existing Download button on file cards / readonly file cards / the
standalone AttachmentList. Clicking it opens a full-screen modal that
dispatches by content_type:

  pdf:      <iframe src={download_url}>           — Chromium PDFium
  video/*:  <video controls src={download_url}>   — native controls
  audio/*:  <audio controls src={download_url}>   — native controls
  md:       <ReadonlyContent>                     — full markdown pipeline
  html:     <iframe srcdoc sandbox="">            — fully restricted
  text:     <code class="hljs">                   — lowlight highlight

Media types render directly from the signed CloudFront download_url
(server marks them inline-disposition). Text types fetch through the
new /api/attachments/{id}/content proxy via TanStack Query, wrapped
in useAttachmentPreview() so each entry point owns its own modal
state without depending on a global Provider mount.

Modal sizing: max-w-6xl × min(90vh, 100vh - 2rem) — slightly larger
than create-issue's max-w-4xl since PDF / video need room, but capped
to viewport on small screens. Sub-renderers use h-full to follow the
fixed modal height instead of viewport-relative units.

Images are intentionally NOT touched — the existing ImageLightbox
(extensions/image-view.tsx) already handles them correctly. The new
modal would be churn without user-visible benefit.

Adds i18n keys under attachment.* (en + zh-Hans) and registers
Preview/Download/Upload in the conventions glossary so future
translations stay consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(desktop): enable Chromium PDF viewer for attachment preview

Adds webPreferences.plugins: true to the main BrowserWindow so the
bundled Chromium PDFium plugin activates inside iframes — required for
the attachment preview modal's PDF dispatch. Default is false in Electron;
without it <iframe src=*.pdf> renders blank.

Security trade-off, accepted intentionally and documented inline:
  1. This window already runs with webSecurity: false + sandbox: false,
     so plugins: true does NOT meaningfully widen the renderer's attack
     surface beyond what is already accepted.
  2. The only PDFs that reach an iframe here are signed CloudFront URLs
     we ourselves issued; user-supplied URLs are routed through
     setWindowOpenHandler → openExternalSafely and cannot land in this
     renderer.
  3. Chromium's PDFium plugin is itself sandboxed and only handles
     application/pdf — no Flash/Java/other historical plugin surfaces.

If we ever tighten webSecurity / sandbox, the follow-up is to host the
PDF viewer in a dedicated BrowserView with plugins scoped to that view,
keeping the main renderer plugin-free.

Old desktop builds ship without the preview modal, so the Eye button
never appears and PDF preview is gated by the same release — zero
regression risk for users on stale clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:24:15 +08:00
Bohan Jiang
e2802a5407 fix(chat): commit rename only on real outside click, not on hover (#2527)
Base UI's Menu uses focus-follows-cursor — hovering a sibling row drags
DOM focus to that row, which made the rename input's onBlur=save fire
just from moving the mouse. The result: clicking the pencil and then
nudging the cursor would silently commit a half-typed title.

Replace the blur handler with a document-level pointerdown listener
(capture phase, so it runs before Base UI's outside-click close handler
unmounts the input). The listener only commits when the user actually
clicks somewhere outside the input. Enter still commits, Escape still
cancels, mouse hover is now a no-op.

MUL-2110

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:23:55 +08:00
Bohan Jiang
51aa924124 feat(chat): support renaming chat sessions inline (#2522)
Adds a pencil icon next to the trash icon on each session row in the chat
dropdown. Clicking it turns the title into an inline editable input:
Enter / blur saves, Escape cancels.

Server: new PATCH /api/chat/sessions/{id} handler that updates the title
via the existing `UpdateChatSessionTitle` sqlc query, broadcasts a new
`chat:session_updated` WS event so other tabs / devices stay in sync, and
rejects blank titles. Frontend mutation is optimistic with rollback,
matching the existing delete-session pattern.

MUL-2110

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 16:57:34 +08:00
Naiyuan Qing
81b62fc8d3 fix(chat): eliminate Skeleton flash on new-chat first message (#2518)
In a new chat (no active session), the first send momentarily rendered
ChatMessageSkeleton before the user's message appeared. Root cause:
ensureSession called setActiveSession(newId) immediately after creating
the session, *before* handleSend wrote the optimistic message to the
chatKeys.messages(sessionId) cache. useQuery's first subscription to the
new key saw no data → isLoading=true → showSkeleton rendered for one
frame.

Apply TanStack Query's "seed the cache before subscription" pattern:
move setActiveSession out of ensureSession and into the callers, after
they've primed the messages cache. handleSend writes the optimistic
user message first, then flips activeSessionId; handleUploadFile seeds
an empty array first, then flips. useQuery's first read hits cache
synchronously and ChatMessageList mounts directly — no Skeleton frame.

This is a distinct race from the chat-done flicker fixed in #2509
(unmount/mount on reply completion); both share the same prime-before-
subscribe shape.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 15:55:59 +08:00
Naiyuan Qing
157498e9fa fix(editor): preserve pasted mentions in instruction editor (#2514)
`disableMentions` previously skipped registering BaseMentionExtension entirely,
which removed the `mention` node type from the editor's schema. Pasting any
ProseMirror slice from another Multica editor (clipboard `text/html` carries
`data-pm-slice`) caused ProseMirror to silently drop the mention nodes and any
surrounding inline text glued to them.

Keep the extension registered in all cases. When `disableMentions=true`, attach
an inert suggestion (`allow: () => false`) so typing `@` still does not pop the
picker — matching the original product intent for agent system prompts — but
existing mentions pasted in survive and render as the normal pill.

Earlier attempt #2477 patched the paste classifier instead and broke in a
different way (`mention://` href tripped the markdown link validator),
which led to revert #2510.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:13:30 +08:00
Shalin
7fcc8159ba fix(desktop): route attachment downloads through Electron native system on Linux (#2441)
* fix(desktop): route attachment downloads through Electron native system on Linux

Replaces shell.openExternal with webContents.downloadURL for attachment
downloads in the Electron desktop app. On Linux/Ubuntu, opening a
CloudFront URL serving Content-Type: text/html via the system browser
causes the browser to render the HTML inline instead of downloading.
Electron's native downloadURL shows a save dialog and saves the file
directly, fixing HTML downloads regardless of Content-Type.

* test(views): update desktop download test to match the new downloadURL bridge

The test still referenced the old openExternal bridge. Updated it to
assert desktopAPI.downloadURL() instead.

* fix(desktop): add URL scheme allowlist to download IPC handler

Addresses review feedback on PR #2441.

The file:download-url IPC handler called webContents.downloadURL
directly, bypassing the http/https allowlist enforced by
openExternalSafely. Adds downloadURLSafely() alongside the existing
openExternalSafely wrapper, reuses the same isSafeExternalHttpUrl
check, and extends the ESLint no-restricted-syntax rule to ban direct
webContents.downloadURL calls.

Also handles nits: observable warning on null mainWindow, removes dead
openExternal field from DesktopBridge, adds desktop-branch failure test.
2026-05-13 14:44:33 +08:00
Naiyuan Qing
b87e54850a Revert "fix: preserve mention markdown in instruction paste (#2477)" (#2510)
This reverts commit 5a9c15bc12.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 14:10:04 +08:00
Bohan Jiang
451c46c43f refactor(usage): rename Dashboard → Usage + dynamic per-agent leaderboard (#2511)
The page added in #2462 lived at `/{slug}/dashboard` and was titled
"Dashboard", which collides with the conventional meaning ("personal
landing surface") and doesn't tell new users what the page is for. Its
actual contents — token spend, cost, run time, task counts — map cleanly
onto the OpenAI / Anthropic / Vercel "Usage" surface, so rename to that.

Renames (user-visible)
- Route: `/{slug}/dashboard` → `/{slug}/usage` (web App Router + desktop
  memory router)
- Sidebar entry: label "Dashboard" / "看板" → "Usage" / "用量", icon
  LayoutDashboard → BarChart3 (page header icon swapped in sync)
- Page title in en/zh-Hans
- Reserved-slugs: add `usage` to workspace route segments group;
  `dashboard` stays reserved in the marketing group (back-compat against
  workspace slug collisions + keeps the name free for a future Home page)
- i18n namespace `dashboard` → `usage` across resources-types.ts,
  locales/index.ts, and the moved JSON files
- WORKSPACE_ROUTE_SEGMENTS in editor link-handler
- paths.workspace(slug).dashboard() → .usage(), with matching test
  expectation updates

Per-agent leaderboard polish (`packages/views/dashboard/components/
dashboard-page.tsx`)
- Card title "Cost & run time by agent" → "Leaderboard" with a 4-way
  Segmented control: Tokens / Cost / Time / Tasks
- Active metric drives row order, progress-bar width, and the
  emphasised column header / cell — keeping ranking, visual quantity,
  and column emphasis in lockstep so users always see what's being
  measured
- Default sort = Tokens (most universally meaningful; Cost still one
  click away)
- Project filter dropdown:
  - Show ProjectIcon next to the selected project + each list item;
    FolderKanban as the "All projects" fallback (matches ProjectPicker
    language)
  - alignItemWithTrigger={false} so "All projects" doesn't get pushed
    above the trigger and clipped when the header sits at the top of
    the viewport (was the root cause of "can't re-select All projects"
    once a project was selected)
  - max-h-72 to cap the dropdown when workspaces accrue many projects;
    matches the runtime-detail Select precedent
- Folder name `packages/views/dashboard/*` and `DashboardPage`
  component name intentionally left in place — user-visible rename
  only, no broad code refactor.

Old `/dashboard` routes are not redirected because the page only landed
in #2462 (a few days ago); no real users, external links, or
desktop-tab persistence have settled on it yet.
2026-05-13 14:07:53 +08:00
Multica Eve
5a9c15bc12 fix: preserve mention markdown in instruction paste (#2477)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 13:55:16 +08:00
Naiyuan Qing
06bcc1fab4 feat(feedback): add file upload button so users can attach screenshots (#2501)
The editor underneath the feedback textarea already supports image/file
upload via paste and drag-drop, but the modal has no visible affordance
— users had no way to discover this. Chat input has the same plumbing
and exposes it through a paperclip button; mirror the pattern here.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 13:33:11 +08:00
Bohan Jiang
96695a79c5 feat(dashboard): workspace/project token + run-time dashboard MUL-1882 (#2462)
* feat(dashboard): workspace/project token + run-time dashboard

Add a `/{slug}/dashboard` page showing per-agent token spend and execution
time across the whole workspace, with an optional project filter.

Backend:
  - Three new sqlc queries against task_usage + agent_task_queue: daily
    usage, per-agent usage, per-agent total run-time. All optionally
    scoped to a project via sqlc.narg('project_id'), reaching project
    through the issue join.
  - Handlers under /api/dashboard return the same wire shape the runtime
    page already consumes (model preserved for client-side cost math).

Frontend: - Shared DashboardPage in packages/views/dashboard reusing KpiCard,
    DailyCostChart, ActorAvatar, and estimateCost from the runtime page
    so the visual style and pricing math stay in lock-step.
  - Period selector (7/30/90d), project dropdown, four KPI tiles
    (cost, tokens, run time, tasks), daily cost chart, and a combined
    "cost + run time by agent" list.
  - Routed in both web (app/[slug]/(dashboard)/dashboard) and desktop
    (memory router); sidebar nav entry added under Workspace group.
Co-authored-by: multica-agent <github@multica.ai>

* fix(dashboard): drop stale project filter and stop double-counting tasks

Two issues caught in PR #2462 review:

1. Project filter held the previous selection's UUID across workspace
   switches and project deletions: the dropdown gracefully showed
   "All projects" (because the title lookup missed) while the three
   dashboard queries kept forwarding the dead UUID, leaving the UI
   looking like a full-workspace view but populated with empty
   project-scoped data. Validate the picked UUID against the current
   projects list before passing it to the queries.

2. The "by agent" table read its task count from the token rollup,
   which is grouped per (agent, model). A single task that spans two
   models lands twice and the agent's row reads e.g. "2 tasks" when
   the real count is 1. Prefer `ListDashboardAgentRunTime`'s per-agent
   distinct count when available; fall back to the token aggregate
   only for agents with no terminal run yet (in-flight tasks).

Extract the merge into `mergeAgentDashboardRows` so the precedence
rules are unit-tested directly.

Co-authored-by: multica-agent <github@multica.ai>

* test(dashboard): allocate per-workspace issue.number explicitly

TestDashboardEndpoints creates two issues in the shared fixture
workspace. issue.number defaults to 0 (migration 020), and the table
carries UNIQUE (workspace_id, number), so the second insert raced the
first on the same default and failed in CI.

Allocate MAX(number) + 1 per insert so each row gets a fresh number
without stepping on rows other tests left behind in the same workspace.

Co-authored-by: multica-agent <github@multica.ai>

* feat(dashboard): rollup table + cron-driven aggregation for dashboard

Mirror the per-runtime rollup in `task_usage_daily` (migrations 073/077/082)
to remove the per-request raw aggregation the dashboard was doing.

Migration 084 adds:
  - `task_usage_dashboard_daily` keyed on
    (bucket_date, workspace_id, agent_id, project_id, model) — the
    dimensions the dashboard actually queries, with project_id nullable
    via UNIQUE NULLS NOT DISTINCT (PG15+) so "no-project" buckets
    upsert cleanly.
  - `task_usage_dashboard_rollup_state` watermark table.
  - `task_usage_dashboard_dirty` invalidation queue.
  - Triggers on agent_task_queue DELETE, task_usage DELETE, and
    issue.project_id UPDATE — the cases the updated_at watermark can't
    see. The project_id trigger re-attributes existing rollup rows when
    a user moves an issue across projects.
  - `rollup_task_usage_dashboard_daily_window(from, to)` —
    idempotent recompute primitive (same shape as 077).
  - `rollup_task_usage_dashboard_daily()` cron entry — own advisory
    lock (4244) so it serialises independently of the runtime rollup.
  - `task_usage_dashboard_rollup_lag_seconds()` health helper.

Sqlc queries `ListDashboardUsageDailyRollup` /
`ListDashboardUsageByAgentRollup` read from the new table; the handler
dispatches between rollup and raw on a separate
`UseDailyRollupForDashboard` config flag
(`USAGE_DASHBOARD_ROLLUP_ENABLED` env). Same fail-safe default (false →
raw) so operators can roll out independently of the per-runtime flag.

Bucket date is UTC (the dashboard aggregates across runtimes that may
sit in different tzs; there's no single correct local boundary).

Adds `cmd/backfill_task_usage_dashboard_daily` mirroring the existing
per-runtime backfill — operator runs it once before flipping the flag.

Tests: - TestDashboardEndpoints now also exercises the rollup read path
    (raw vs. rollup, same project-scoped totals).
  - TestDashboardRollupReattributesOnProjectChange verifies the
    issue.project_id trigger enqueues both old + new buckets and the
    next rollup tick zeroes the old project + populates the new one.
Co-authored-by: multica-agent <github@multica.ai>

* fix(dashboard-rollup): close two invalidation gaps

Two leak paths missed by migration 084 review:

1. Issue cascade DELETE — the atq BEFORE DELETE trigger runs AFTER the
   issue row is gone, so `LEFT JOIN issue` returns NULL project_id and
   the original-project bucket never gets cleared (issue 077 calls this
   out for the runtime rollup but didn't need to act on it). Adds an
   `issue BEFORE DELETE` trigger that enqueues using OLD.project_id
   while the issue row is still readable.

2. `LinkTaskToIssue` (quick-create task attaching to a real issue post-
   completion) UPDATEs `agent_task_queue.issue_id` from NULL to a real
   id. Migration 084 only watched DELETE on atq, so usage already
   rolled up under the no-project bucket stayed attributed to NULL
   forever. Extends the atq trigger to fire on UPDATE OF issue_id too,
   enqueueing both OLD (NULL project) and NEW (linked issue's project).

Tests: - TestDashboardRollupClearsOnIssueDelete asserts rollup row drops to
    zero after issue delete + rollup tick.
  - TestDashboardRollupReattributesOnLinkTaskToIssue verifies tokens
    move from the NULL bucket to the project bucket after the UPDATE.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 12:51:16 +08:00
yuhaowin
24a59098d6 fix(projects): make GitHub repo list scrollable in Add Resource popovers (#2490)
* fix(projects): make GitHub repo list scrollable in Add Resource popover

When a workspace has many GitHub repos, the list in the Add Resource
popover extended beyond the visible area with no way to scroll. Add
max-h-48 overflow-y-auto to the repos container to enable scrolling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): make GitHub repo list scrollable in create project modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 22:44:44 +08:00
Bohan Jiang
e3e61c161c fix(inbox): show Multica logo for system-actor notifications (#2479)
Notifications from system actors (e.g. GitHub PR closed) were rendering
with an "S" initials fallback. The avatar now shows the Multica icon
when actor_type === "system", matching the platform's brand.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 18:06:42 +08:00
Naiyuan Qing
61ca43835a fix(issue-detail): drop virtualization when deep-linking, restore reliable landing (#2472)
Virtualization and precise deep-link landing have fundamentally opposed
contracts: virtualization uses estimated heights for off-screen items,
deep-link needs real heights for everything above the target. Three
prior fix attempts (initial scrollToIndex race, settle-by-silence
observer, 3-pass cooperative scroll) all tried to satisfy both in one
path and none fully stabilized — code/image/mermaid-heavy comments
kept drifting the target after first landing.

Split by user intent instead:
- highlightCommentId set (user came from inbox to read a specific
  comment) -> render flat. Every comment mounts, every height is real,
  the target id is in the DOM the instant the effect runs. Native
  document.getElementById + el.scrollIntoView({block:'center'}) is
  semantically identical to a native <a href="#comment-X"> anchor.
- otherwise -> Virtuoso. Browsing mode keeps the first-paint perf win
  from #2413 on long timelines.

Deep-link effect collapses to ~22 lines, matching the pre-virtualization
implementation. A shared renderItem function keeps both render modes
consistent. Removes: bootstrapRef, three-pass scrollToIndex effect,
overflow-anchor:none, scrollPaddingTop on container, scroll-margin-top
on every comment wrapper, virtuosoRef + VirtuosoHandle, initialItemCount
prop, useLayoutEffect.

Mermaid gets a 280px skeleton (web.dev CLS guidance) plus a
sessionStorage layout cache keyed by chart-text hash, so the 0px ->
real-height shift no longer drifts the surrounding layout — useful for
both render modes, deep-link or browsing. Pattern matches ant-design/x
#1497 which fixes the same Mermaid drift in their own stack.

Auto-expand a folded resolved thread when the deep-link target is a
reply inside it; without this the target reply stays collapsed and the
user sees only the resolved-bar.

Net: +131 / -245 in issue-detail.tsx. Tests added for the
resolved-thread-reply auto-expand path.

Known follow-ups:
- <ReadonlyImage> aspect-ratio for image CLS (same class as Mermaid).
- Layout heisenbug (page width "abnormal" without devtools open) is
  orthogonal to deep-link and survives this PR; needs separate triage.
- 500+ comment cold mount in deep-link mode pays full markdown+lowlight
  cost; GitHub takes the same hit and we accept it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:35:14 +08:00
Bohan Jiang
f17acc21de refactor(integrations): drop installation list from Settings tab (#2468)
The card displayed a per-installation row (avatar + account_login +
"User|Organization · connected <date>") plus a disconnect button. In
practice the title regularly fell back to "unknown" because the server's
fetchInstallationAccount call doesn't sign App JWT, and the
account-level framing also leaked GitHub's data model into the UX —
users care about which repos are wired up, not which GitHub account the
App is installed on.

Collapse the card to: GitHub mark + description + Connect button (plus
the "not configured" hint and role gate). Existing installations stay
fully manageable from GitHub's own settings page, reachable via Connect.

Removes:
- installation list + disconnect button + handleDisconnect
- useQueryClient / Trash2 / githubKeys imports
- five now-dead i18n keys (loading / empty / connected_at /
  toast_disconnected / toast_disconnect_failed) in en + zh-Hans
2026-05-12 15:04:41 +08:00
Naiyuan Qing
01bcede2ad feat(issues): confirm before terminating a single task (#2466)
The two issue-detail surfaces that stop a single agent task — the
sticky AgentLiveCard banner and the active rows inside
ExecutionLogSection — cancelled on the first click. Task
cancellation is irreversible, and a misclick on a long-running run
was costly with no way to recover.

Both entry points now route through a shared
TerminateTaskConfirmDialog (AlertDialog with destructive confirm),
mirroring the pattern the Agents list row actions already use for
the "cancel all tasks" flow. The running-state note about a few
seconds to fully halt is only shown when the task is actually
running or dispatched.

Chat window pending-pill Stop is intentionally not affected — it
is fire-and-forget with the UI clearing optimistically, and a
confirm step there would interrupt chat flow.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:02:07 +08:00
YYClaw
0e7fa21832 fix(runtimes): correct broken docs link to /docs/daemon-runtimes (#2465)
The 'Learn more' link on the Runtimes page pointed to
https://multica.ai/docs/runtimes which returns 404. The docs page is
published at /docs/daemon-runtimes.
2026-05-12 15:00:45 +08:00
Bohan Jiang
caeb146bac feat(github): GitHub App integration for PR ↔ issue linking (#1817)
* feat(github): GitHub App backend for PR ↔ issue linking

- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.

Co-authored-by: multica-agent <github@multica.ai>

* feat(github): UI for connecting GitHub and viewing linked PRs

- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast

- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): i18n the new GitHub UI strings to satisfy lint

CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 13:49:03 +08:00
Naiyuan Qing
a1c2d53939 fix(chat): keep editor mounted across lazy session creation (#2457)
The first file upload in a brand-new chat showed the blob preview for
a moment and then disappeared — the upload looked like it had failed
even though the attachment was actually saved.

Root cause: `<ContentEditor key={draftKey}>`. `draftKey` includes
`activeSessionId`, and `handleUploadFile` (chat-window.tsx) awaits
`ensureSession("")` before forwarding the file to the upload handler.
Lazy-create flips `activeSessionId` from null to a uuid mid-upload,
which changes `draftKey`, which forces React to remount the editor.
The blob image node inserted by `uploadAndInsertFile` was on the old
editor instance; by the time the upload settled, the swap-to-CDN-URL
walk in file-upload.ts couldn't find the blob src in the new editor
and finally `URL.revokeObjectURL` released the blob — broken image.

The create-issue modal has the same draft-store pattern but does not
hit this bug because it never sets a `key` on its ContentEditor; the
editor lives for the lifetime of the modal regardless of draft churn.

Split the two concerns the previous `draftKey` was conflating:

- `draftKey` (zustand storage key) keeps `activeSessionId` so each
  session gets its own draft slot — unchanged behaviour.
- `editorKey` (React identity key) drops `activeSessionId` and only
  varies on `selectedAgentId`, which is the actual signal Tiptap's
  Placeholder needs to refresh on agent switch.

Now the editor stays mounted across the lazy session creation. The
blob preview survives long enough for the swap to find it, and the
user sees the image render normally on the very first upload of a new
chat.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:20:20 +08:00
ayakabot
da03c83251 fix(modals): correct text input height in issue creation dialog (#2434)
* fix(modals): correct text input height in issue creation dialog

Fixed text input height for both agent and manual create issue dialogs:
- Agent dialog: added flex to outer div and flex-1 to inner div
- Manual dialog: added flex to description container and flex-1 to editor

Fixed: #2433

* fix(editor): make EditorContent a proper flex container

- EditorContent: flex flex-1 flex-col
- Remove min-height: 100% from .ProseMirror CSS
- Let flex-grow handle height consistently across the chain

Fixed: #2433

---------

Co-authored-by: ayakabot <ayakabot@seepine.com>
2026-05-12 12:13:45 +08:00
Naiyuan Qing
23c05f13c4 refactor(feedback): replace generic description with brand-colored GitHub CTA (#2455)
* refactor(feedback): replace generic description with brand-colored GitHub CTA

The Feedback modal previously rendered three lines of grey copy before the
editor — title, description, and the GitHub hint from #2451. The hint blended
into the description, defeating its purpose of nudging users toward a tracked
channel.

Drop the generic description (placeholder already explains what to type) and
restyle the hint so GitHub itself is the only brand-coloured anchor. The
shorter sentence ("Want faster traction? Head to GitHub") puts the link at
the natural end-of-line fixation point, where the colour shift actually
registers.

i18n splits into prefix + link (suffix would be empty), avoiding the
sentence-order brittleness that 3-key splits usually introduce.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* copy(feedback): expand GitHub hint to highlight discussion as well

Reviewer feedback: "faster traction" only signals speed; users also care about
having an open back-and-forth on a tracked thread. Update the hint to surface
both benefits without lengthening the line meaningfully.

- EN: "Want faster handling and open discussion? Head to GitHub"
- ZH: "想被更快处理、参与讨论?请去 GitHub"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 11:36:53 +08:00
Naiyuan Qing
b21f69f31a fix(views): land deep-link via cooperative scroll passes (#2452)
Replaces #2452's first attempt (placeholder-freeze, 800ms blank
window) and the multi-observer settle pipeline from #2449. Both
were trying to land the target with a single perfectly-timed scroll,
which doesn't compose with how virtualization actually works.

The non-virtualized version of this code, pre-#2413, was 12 lines:
one el.scrollIntoView once timeline.length > 0 && !loading. That
worked because every comment was in the DOM, so the target's
absolute position was real, not estimated. Virtualization breaks
that invariant — Virtuoso renders a window, fills the rest with
spacer heights derived from estimates, and the target's offset is
spacer-sum until each above-target item is mounted and measured for
the first time. Those measurements arrive in waves: viewport mount,
ResizeObserver pass, markdown render, lowlight code highlight,
image load. Each wave updates spacers and shifts the target's
offset by tens to hundreds of pixels.

The previous two attempts both tried to detect "settle" and land
once. ResizeObserver on the target watches the symptom, not the
cause (#2449). Rendering placeholders to freeze the cause shows
800ms of blank where comments should be (#2452 v1).

This rewrite cooperates with Virtuoso's own measure→correct loop
instead of trying to outrun it. Three scrollToIndex calls — t=0,
t=120 (after the first measurement wave), t=500 (after markdown /
lowlight settle) — let the convergence narrow on each pass. Each
call uses whatever spacer heights are current; differences across
passes are typically a few pixels (cold viewport) to a few dozen
(big code blocks), not the full-spacer drift that motivated
placeholders. Visually it reads as a single instant scroll with at
most a couple of subtle re-centerings, not a re-jump.

initialTopMostItemIndex stays — it's the only API that anchors
position *before* first paint, and it's the reason cold-start
deep-links from inbox land at the target without a visible "scroll
from top". Captured exactly once via a useRef one-shot following
React's documented "avoid recreating ref contents" idiom, so #458's
persistent-anchor reset behavior can't trip. Crucially we now
spread-on-defined rather than passing `={undefined}` — react-virtuoso
crashes with "Cannot read properties of undefined (reading 'index')"
on the latter because the library accesses .index on the prop without
a null guard.

Net delta vs main: −86 lines. Deletes ~150 lines of the #2449
MutationObserver/ResizeObserver settle pipeline plus this PR's
prior placeholder/deepLinking/flushSync machinery, replaces with
~30 lines of straightforward effect + bootstrap ref. The whole
deep-link path is now smaller than the original pre-virtualization
version was, because the convergence loop is explicit and the
correctness story doesn't require auxiliary state.

Refs: react-virtuoso #458 (initialTopMostItemIndex anchor reset),
#883 (initial scroll race), #1083 (scrollTop model divergence vs
native scrollIntoView).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:23:02 +08:00
Naiyuan Qing
723489d2a9 feat(feedback): nudge users toward GitHub for discussion and faster traction (#2451)
Add a small CTA below the Feedback modal description that links to
github.com/multica-ai/multica/issues for users who want a tracked, public
channel. The in-app feedback form still serves vague impressions and
weekly-aggregated input; GitHub is for concrete bugs, feature requests, and
discussion that benefits from community visibility.

i18n covers en + zh-Hans following the conventions.zh.mdx voice guide
(full-width punctuation, ASCII ellipsis, spaces around Latin terms).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 11:00:39 +08:00
Naiyuan Qing
86aa5199fc feat(chat): support attachments & images in chat input (#2445)
* docs(plans): chat attachment & image support implementation plan

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(db): add chat_session_id/chat_message_id to attachment

Co-authored-by: multica-agent <github@multica.ai>

* feat(db): sqlc — chat_session_id on CreateAttachment + LinkAttachmentsToChatMessage

Co-authored-by: multica-agent <github@multica.ai>

* feat(file): upload-file accepts chat_session_id form field

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): SendChatMessage links uploaded attachments to the new message

Co-authored-by: multica-agent <github@multica.ai>

* feat(api): uploadFile accepts chatSessionId; sendChatMessage accepts attachmentIds

Co-authored-by: multica-agent <github@multica.ai>

* feat(core): useFileUpload supports chatSessionId context

Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): support paste/drag/upload attachments in chat input

Co-authored-by: multica-agent <github@multica.ai>

* test(e2e): chat input attachment upload + send round-trip

Co-authored-by: multica-agent <github@multica.ai>

* chore(chat): keep lazy-created session title empty so untitled fallback localizes

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review — dedupe ensureSession + parse upload response

- chat-window: cache in-flight createSession promise in a ref so a file drop
  followed by a quick send no longer spawns two sessions (and orphans the
  attachment on the losing one).
- Attachment type + EMPTY_ATTACHMENT + AttachmentResponseSchema: include the
  new chat_session_id / chat_message_id fields the server now returns.
- uploadFile: route the response through parseWithFallback so a malformed
  body returns EMPTY_ATTACHMENT instead of an undefined-keyed Attachment,
  matching the API boundary rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address PR #2445 review — test ctx, send gating, attachment surface

1. Backend test was 400ing because the handler reads workspace from
   middleware-injected ctx, and `newRequest` only sets the header. Helper
   `withChatTestWorkspaceCtx` mirrors the agent-access-test pattern and
   loads the member row + SetMemberContext before invoking the handler.

2. Attachment metadata now flows end-to-end:
   - new sqlc `ListAttachmentsByChatMessageIDs` (batch lookup, mirrors the
     comment-side query)
   - `chatMessageToResponse` takes `attachments` and `ChatMessageResponse`
     surfaces them — same shape as CommentResponse
   - `ListChatMessages` loads them via a new `groupChatMessageAttachments`
     helper so the chat bubble can render file cards
   - daemon claim path pulls `ListAttachmentsByChatMessage` for the latest
     user message and ships `ChatMessageAttachments` to the daemon
   - `buildChatPrompt` lists id+filename+content_type and instructs the
     agent to `multica attachment download <id>` — fixes the private-CDN
     expiring-URL problem where the markdown URL would have expired by
     the time the agent acts
   - TS `ChatMessage` gains an optional `attachments` field

3. Chat composer now blocks send while uploads are in flight:
   - `pendingUploads` counter increments in handleUpload, SubmitButton
     uses it to disable
   - handleSend also gates on `editorRef.current.hasActiveUploads()` to
     catch the Mod+Enter path that bypasses the button
   - new vitest covers the "drop large file → immediate send" scenario
     where attachment id would otherwise be silently dropped

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore: drop implementation plan doc

Process artefact, not something the repo needs to keep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 10:57:54 +08:00
Naiyuan Qing
208f1ddb29 fix(views): land virtualized deep-link via settle-by-silence (#2449)
The earlier deep-link fix (0d0d100e) used a fixed 20-frame rAF poll to
wait for Virtuoso to mount the target before handing off to the
browser's native scrollIntoView. That approach failed under three
conditions all reproduced on the 500-comment perf fixture:

 1. Items near the bottom of long lists: Virtuoso's estimate→mount→
    ResizeObserver→correction sequence stretches past 320ms; the
    poll gave up and set highlight without scroll.
 2. Tall markdown/code-block comments: the target mounted within the
    poll window but its measured height was not yet final (lowlight
    was still highlighting). scrollIntoView landed on the not-yet-
    reflowed card; the card grew a moment later and dragged the
    target out of view.
 3. Late image loads or any post-mount layout shift inside the
    timeline: the browser's built-in CSS scroll-anchoring silently
    nudged scrollTop after we had already finished, putting the
    target back off-center.

The root cause is the same race that every variable-height
virtualizer has — official react-virtuoso #1263 calls it out as
intentional, and #1296 shows even Virtuoso's own `scrollIntoView({done})`
callback is unreliable across the same scenarios. The fix is
virtualizer-agnostic: don't trust *any* "we landed" signal the
virtualizer gives you. Wait for the real DOM node to stop reflowing
before handing off to the browser.

Four phases now:

  Phase 1 (coarse): virtuosoRef.scrollToIndex only to *mount* the
    target. The scroll position it produces is discarded.
  Phase 2 (adopt):  MutationObserver on the scroll container picks
    up the target node as soon as it enters the DOM.
  Phase 3 (settle): a ResizeObserver on the target with a
    "settle-by-silence" timer — every RO tick re-arms a 120ms idle
    window; when the window elapses with no further ticks the card
    is treated as stable. Baseline 150ms timer so a fully-static
    card (or test env with stubbed RO) still proceeds.
  Phase 4 (land):   native el.scrollIntoView({block:'center'}), then
    light the highlight on `scrollend` (or a 200ms fallback for
    Safari < 17.4 and jsdom, both of which never fire scrollend).

Hard 2.5s cap on the whole pipeline so a comment whose images load
indefinitely doesn't leak observers; in that case we still attempt a
final scroll with whatever's measured and flash the highlight so a
manual scroll lands on a marked card.

CSS partner: `overflow-anchor: none` on the scroll container disables
the browser's automatic re-anchoring on layout shifts above the
viewport. Without this even a perfectly-landed scrollIntoView can be
silently nudged off-target by a late ResizeObserver pass on a
comment above the viewport.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:15:05 +08:00
Bohan Jiang
2eefa3b90b refactor(runtime): move visibility description to hover tooltip (#2435)
The Diagnostics card's Visibility section had a two-line layout — icon +
label on top, descriptive hint underneath — which made it look noisy next
to the compact Timezone / CLI sections. Move the hint into a tooltip on
hover and collapse the buttons into a tight segmented-toggle pair
matching the runtimes-page Mine/All filter pattern. Readout side mirrors
the change: chip-only, full description on hover.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 23:07:38 +08:00
Bohan Jiang
63d215e1c3 feat(runtime): visibility (public/private) gate on CreateAgent / UpdateAgent (#2419)
* feat(runtime): visibility (public/private) gate on CreateAgent / UpdateAgent

Closes the hole where a plain workspace member could pick another member's
runtime in the Create Agent dialog and bind an agent to it — the backend
wasn't checking runtime ownership, so the agent ran on someone else's
hardware / tokens. Reported on GH #1804.

Schema
- Migration 083 adds agent_runtime.visibility ('private' default, 'public')
  with a CHECK constraint. Existing rows default to private — same
  ownership semantics as before, no behavior change for legacy data.

Backend
- canUseRuntimeForAgent predicate: allow when caller is workspace
  owner/admin, the runtime owner, or the runtime is public.
- CreateAgent and UpdateAgent both gate on it: UpdateAgent matters because
  a plain member could otherwise create on their own runtime, then re-bind
  to a private one.
- PATCH /api/runtimes/:id accepts { visibility } — owner/admin only,
  validated against the same private/public allow-list.

Frontend
- Create-agent dialog renders other-owned private runtimes disabled with a
  Lock badge + tooltip explaining who to ask.
- Inspector runtime-picker disables the same set so re-binding fails
  the same way at the UI layer.
- Runtime detail diagnostics gains a Visibility editor (owner/admin) or
  read-only chip (everyone else).
- Runtime list shows a private/public chip next to the name.

Tests
- Go: canUseRuntimeForAgent truth table; CreateAgent / UpdateAgent
  end-to-end gate tests (admin / runtime owner / plain member);
  PATCH visibility owner / admin / member / invalid-value coverage.
- Vitest: create-agent dialog disabled state on private/public runtimes,
  default-runtime selection skips locked rows; runtime detail visibility
  editor → mutation, read-only fallback.

Migrating runtimes: existing rows default to private to preserve the
"owner only" status quo. Owners switch to public via the detail page
diagnostics card.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtime): apply timezone+visibility atomically; don't seed locked template runtime

Two issues surfaced in review of MUL-2062:

1. PATCH /api/runtimes/:id ran the timezone branch first, which:
   - returned early on a tz no-op, silently dropping a concurrent
     `visibility` patch in the same body;
   - committed the timezone mutation (+ usage rollup rebuild) before
     validating visibility, so an invalid visibility left the row
     half-updated.

   Validate every field first, then run the mutations in order. The
   no-op short-circuit now only triggers when nothing else is requested.

2. The Create Agent dialog in duplicate mode unconditionally seeded
   `template.runtime_id` as the selected runtime, even when that runtime
   is now private and owned by someone else — the user saw a selected
   row they couldn't submit (Create → backend 403). Fall back to the
   first usable runtime when the template's runtime is locked, and gate
   the Create button on `selectedRuntimeLocked` as defense in depth.

Tests:
- Go: TestUpdateAgentRuntime_CombinedPatchAppliesBoth (tz no-op +
  visibility flip), TestUpdateAgentRuntime_InvalidVisibilityDoesNotMutateTimezone
  (atomic-fail invariant).
- Vitest: duplicate template pointing at a locked runtime now seeds
  the first usable one; Create button stays disabled when no usable
  alternative exists.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 22:53:07 +08:00
Naiyuan Qing
fb8ad8cc5e perf: virtualize issue detail timeline + seed test scaffolding (#2413)
* perf(views): virtualize issue detail timeline with react-virtuoso

The unvirtualized timeline at issue-detail.tsx full-mounted every
entry, freezing first paint for several seconds at 500+ comments
(markdown parse + lowlight per CommentCard on mount). Production p99
is ~30 comments but the all-time max is ~1.1k and the server hard-caps
at 2000 — long-tail issues were unusable.

Swap the inline `.map` for `<Virtuoso customScrollParent>` driven by a
flattened TimelineItem discriminated union. TanStack Query stays the
source of truth; existing memo machinery (`prevThreadRepliesRef`,
`EMPTY_REPLIES`) and WS handlers are untouched. `followOutput="auto"`
matches Slack/Discord — users at the bottom auto-follow new comments,
users mid-scroll are not yanked back down.

Comment drafts move to a new persisted Zustand store
(`comment-draft-store`) so virtualization-driven unmount can no longer
drop in-progress edits or new comments. Hydrates via ContentEditor
`defaultValue`, flushes on update / blur / visibilitychange.

Deep-link from inbox is rewritten from `getElementById` +
`scrollIntoView` to `virtuosoRef.scrollToIndex` with a double-rAF
mitigation for the Virtuoso #883 initial-scroll race. Highlight flash
bumped 2s→3s to outlast mount latency on cold cards.

Cmd-F shows a once-per-session toast on long timelines since browser
find-in-page can't reach off-screen virtualized items. Real in-app
search lands in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(views): repair deep-link scroll and isolate comment drafts

The first virtualization landing had three latent issues that runtime
testing on perf fixtures (10 → 5000 comments) exposed:

1. Deep-link landing position was wrong by ~380px on every issue.
   In customScrollParent mode Virtuoso computes scrollTop from the
   list's internal coordinate space only — it doesn't account for
   sibling content (title editor, description, sub-issues, agent
   card) sitting above the list inside the same scroll parent. The
   useEffect now uses Virtuoso scrollToIndex only to MOUNT the
   target into the DOM, then polls a `data-comment-id` anchor and
   delegates positioning to the browser's scrollIntoView, which
   honors getBoundingClientRect and lands accurately every time.

2. Scroll-up was being yanked back to the deep-link anchor on every
   ResizeObserver tick. Root cause was `followOutput="auto"`, which
   stays "stuck to bottom" once the deep-link lands there and resets
   scrollTop to maxScrollTop on each height change. Issue detail is
   document-shaped, not chat-shaped, so removing followOutput
   altogether is the right tradeoff. Likewise `initialTopMostItemIndex`
   acts as a persistent anchor in customScrollParent mode (Virtuoso
   #458) — dropped entirely and replaced with imperative scroll.
   `defaultItemHeight` is also dropped so Virtuoso probes real
   heights instead of estimating + correcting visually.

3. Reply-comment deep-links from the inbox would short-circuit
   because the reply id isn't in the flat items[] array. Added a
   replyToRoot map so deep-link falls back to the enclosing thread's
   root index, scrolls there, and lets the reply's own ring fire
   once the thread is in view.

Also fixes a latent cross-issue draft leak in `<CommentInput>`:
web's /issues/[id] route doesn't remount IssueDetail on issueId
change, so without an explicit `key={id}` the editor kept the
previous issue's in-memory content and the next keystroke would
flush it under the new issue's draft key. The same fix incidentally
repairs the pre-existing "submit composer from issue A while viewing
issue B" submit-target bug.

Highlight UX polish: bg-brand/5 was too faint to notice; ring upgraded
to ring-brand/60 as the sole signal. transition-colors didn't actually
animate ring/box-shadow — switched to transition-shadow duration-500
ease-out so highlight has visible fade in / fade out. Flash duration
3s → 4s. Polling failure now still sets highlight + warns so a manual
scroll to the target still flashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:56:27 +08:00
YYClaw
2e5e3a7189 fix(core): stop leaking recent issues across workspaces (#2403)
* fix(core): namespace recent-issues by workspace id in state

The recent-issues store was using createWorkspaceAwareStorage, which
namespaces the storage key by the current slug. That broke whenever a
setter ran before WorkspaceRouteLayout's mount-effect set the slug —
child effects fire before parent effects in React, so recordVisit from
issue-detail wrote to the un-namespaced bare key, leaking visits across
workspaces. The /<slug>/issues page then fanned out a per-id GET for
each leaked id, mostly 404s.

Move the namespacing into the store state itself (byWorkspace keyed by
wsId), so reads/writes pick the right bucket at call time and don't
depend on a singleton being set before module hydration. Drop the
storage-level namespacing and the rehydration registration for this
store.

Add pruneWorkspaces to evict buckets for workspaces the user is no
longer a member of, wired into useDashboardGuard so it runs whenever
the workspace list resolves. As a defense against the prune never
firing, cap the total tracked workspaces at 50 (LRU on oldest visit).

Bump persist version to 1; the v0 entries don't know which workspace
they belonged to, so migrate drops them and the cache repopulates as
the user visits issues.

* fix(core): fail closed on null slug in workspace-aware storage

createWorkspaceAwareStorage used to fall back to the un-namespaced bare
key when no workspace was active. That fallback let any setter firing
before WorkspaceRouteLayout's mount-effect (e.g. a child component's
own mount-effect) leak workspace-scoped data into a global slot
visible to every workspace. Initial zustand persist hydration also ran
in this null-slug window, so every store would read the polluted bare
key on first load.

Drop the fallback: null slug → getItem returns null, setItem/removeItem
are no-ops. Stores still get a correct read via their registered
rehydrate fn once setCurrentWorkspace fires. The remaining nine stores
using this storage no longer rely on the bare-key path either; their
data has always been intended to be workspace-scoped.

---------

Co-authored-by: YYClaw <yyclaw0@gmail.com>
2026-05-11 16:56:54 +08:00
Bohan Jiang
352e838b01 fix(attachments): re-sign CloudFront download URLs at click time (#2407)
* fix(attachments): re-sign CloudFront download URLs at click time

The attachment download buttons opened `download_url` directly from cached
timeline/comment payloads. The signed URL is valid for 30 minutes, so a page
left open past that window would 403 with `AccessDenied` (MUL-2038 /
GitHub #2397).

- Add `GET /api/attachments/{id}` client method that re-signs on every call,
  validated by a stricter `AttachmentResponseSchema` (enforces `url`,
  `download_url`, `filename` so a malformed response degrades to the
  EMPTY_ATTACHMENT record instead of opening `undefined`).
- Introduce `useDownloadAttachment` hook with two execution shapes:
  - Web: synchronously open `about:blank` inside the click gesture to keep
    popup activation, then hydrate `location.href` after the fetch. Cannot
    pass `noopener` here — HTML spec dom-open step 17 makes that return
    null.
  - Desktop: skip the placeholder (Electron's setWindowOpenHandler rejects
    about:blank) and hand the fresh URL to `openExternal`.
- Wire the hook into the standalone attachment buttons (comment-card) and
  the inline `<img>` / file-card buttons inside `ReadonlyContent`. Inline
  buttons resolve the attachment id by URL match; external URLs fall back
  to `openExternal`.

Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): re-sign downloads from ContentEditor file/image NodeViews

The previous commit only wired the click-time fresh-sign through
ReadonlyContent + the standalone attachment list. The Tiptap NodeViews
inside ContentEditor still opened the raw URL with
`window.open(href, "_blank", "noopener,noreferrer")`, leaving two
download surfaces on stale signatures:

- Issue description (always renders via ContentEditor)
- Comment edit mode (transient ContentEditor instance)

- Add AttachmentDownloadContext + AttachmentDownloadProvider so NodeViews
  can resolve markdown URLs to an attachment id and call the existing
  `useDownloadAttachment` hook. The default fallback (no provider mounted)
  hands the raw URL to `openExternal`, keeping non-editor mounts unaffected.
- ContentEditor accepts `attachments?: Attachment[]` and wraps EditorContent
  with the provider.
- file-card.tsx and image-view.tsx NodeViews swap their `window.open(...)`
  calls for `openByUrl(href|src)` from the provider.
- issue-detail.tsx threads `useQuery(issueAttachmentsOptions(id))` into
  ContentEditor for the description.
- comment-card.tsx passes `entry.attachments` to both edit-mode editors.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 16:33:41 +08:00
Naiyuan Qing
34a7ba9865 fix(chat): unify chat and comment send shortcut to Mod+Enter (#2398)
Chat input had `submitOnEnter` enabled while the comment editor used
`Mod+Enter`. Two consequences:

- Inconsistent muscle memory between the two inputs.
- In chat, bare Enter sending stole the only key that continues a
  TipTap bullet/ordered list. Shift+Enter falls through to HardBreak
  (a <br> inside the same list item), so bullet lists were stuck at
  one item.

Drop `submitOnEnter` from the chat input so it follows the editor
default. Mod+Enter (⌘↵ / Ctrl+Enter) sends in both places; bare Enter
now continues lists and inserts paragraphs as users expect.

Surface the shortcut on the SubmitButton via a new optional `tooltip`
prop, and route the comment input through SubmitButton instead of an
ad-hoc Button — same affordance, deduped.

Add unit coverage for the submit-shortcut extension that pins
Mod-Enter, the submitOnEnter=false case, IME, and code-block guards.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:39:37 +08:00
Multica Eve
d6349c16ec feat(runtime): per-runtime timezone for token-usage aggregation (MUL-1950) (#2394)
* feat: per-runtime timezone for token usage aggregation

The runtime token-usage charts (daily and hourly tabs on the
runtime-detail page) bucketed every event by the Postgres session
timezone, which is UTC in production. For an operator in UTC+8 that
meant a Tuesday afternoon's tasks landed in Tuesday early-morning's
bar — the chart was always one off.

Fix: store an IANA timezone on agent_runtime and aggregate under it.

* migrations 081 / 082 add agent_runtime.timezone (TEXT NOT NULL
  DEFAULT 'UTC') and rebuild the rollup pipeline (window function
  and both trigger functions) to compute bucket_date with
  AT TIME ZONE rt.timezone instead of bare DATE().
* No historical backfill — task_usage_daily rows already on disk
  keep their UTC bucket_date; only future writes / re-touches
  recompute under the new tz. (Product call from MUL-1950: 'guarantee
  future correctness'.)
* runtime_usage.sql gains a @tz parameter on ListRuntimeUsage and
  GetRuntimeUsageByHour and threads tz through GetRuntimeTaskHourly  Activity. ListRuntimeUsageDaily reads bucket_date as-is since the
  rollup already wrote it in tz.
* parseSinceParamInTZ replaces the raw N×24h cutoff with start-of-
  day-N in the runtime's tz so 'last 7 days' lines up with bucket
  boundaries.
* Daemon registration sends the host's IANA tz (TZ env, then
  time.Local), and UpsertAgentRuntime preserves any user override
  via a CASE-on-existing-value pattern so a daemon reconnect can't
  silently revert the operator's setting.
* New PATCH /api/runtimes/:id endpoint (UpdateAgentRuntime) lets
  the runtime detail page edit the tz; the editor seeds with the
  browser tz on first interaction.

Refs: MUL-1950

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix: harden runtime timezone rollups

Co-authored-by: multica-agent <github@multica.ai>

* fix: address runtime timezone review nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-05-11 14:39:35 +08:00
Bohan Jiang
6e3e6f714c feat(runtimes): let users set custom prices for unmaintained models (#2386)
* feat(runtimes): let users set custom prices for unmaintained models

The Runtime > Usage pricing diagnostic previously told users to "edit
packages/views/runtimes/utils.ts" when a model wasn't priced. That's
fine for us, useless for everyone else. We can't track every model
release, so let users supply their own per-million-token rates for
anything we don't ship a maintained rate for (e.g. gpt-5.5-mini today).

- Add a persisted Zustand store (custom-pricing-store) keyed by model
  name; rates live in localStorage so they survive reloads.
- resolvePricing consults the maintained MODEL_PRICING catalog first,
  then falls back to the store. Catalog still wins on overlap so a
  stale local override can't shadow a known rate.
- EmptyChartState gains a "Set custom prices" button when unmapped
  models exist; the dialog lists every unmapped model plus everything
  already overridden so users can edit / clear prior entries.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): show pricing-gap notice for partial unmapping; invalidate cost memos on price save

Two bugs surfaced in review:

1. The "Set custom prices" CTA only showed inside EmptyChartState, which
   only fires when Daily / Hourly total cost is exactly 0. Mixed windows
   (some priced + some unpriced models) rendered the chart normally and
   left no entry point — the unpriced tokens silently contributed \$0
   to totals.

   Add a permanent UnmappedPricingNotice above the KPI grid that appears
   whenever collectUnmappedModels(filtered) is non-empty, regardless of
   chart state. EmptyChartState keeps the diagnostic text but the CTA
   button moves to the notice so the two surfaces don't duplicate.

2. The aggregate useMemo blocks (WhenChart's dailyCostStack / hourlyCost,
   CostByBlock's byAgent / byModel, ActivityHeatmap's cells) keyed only
   on their query data. After a price save the parent re-rendered, but
   the memos returned cached pre-save totals because their deps were
   identical. The KPI cards updated; the charts did not.

   Subscribe to the pricing store in each aggregating component and
   list `pricings` as a memo dependency. The store returns a stable
   reference until setCustomPricing fires, so memos only invalidate
   on real changes.

New unit tests cover both: a mixed priced/unpriced aggregate produces
mixed costs (and surfaces the unpriced names), and aggregateCostByModel
called twice on the same input array reflects a freshly-saved override.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:36:14 +08:00
Bohan Jiang
b26f850d4e feat(agents): gate private-agent surfaces with allowed_principals predicate (#2359)
* feat(agents): gate private-agent surfaces with allowed_principals predicate

Tighten chat/@-mention, history, edit, and delete entry points so private
agents are only reachable by their owner or workspace owner/admin. Agent-to-
agent traffic still bypasses the gate so A2A collaboration keeps working.

- New canAccessPrivateAgent predicate in handler/agent_access.go; used by
  comment.enqueueMentionedAgentTasks (replacing the inline check), GetAgent,
  ListAgents (filter), ListAgentTasks, GetWorkspaceAgentRunCounts /
  Activity30d / TaskSnapshot (workspace-wide aggregations no longer leak
  private-agent existence + counts), chat.CreateChatSession,
  chat.SendChatMessage (re-checks on every send so role changes can't leave
  a stale session as a back-door), and autopilot.shouldSkipDispatch
  (caller = autopilot creator).
- allowed_principals is computed inline as {agent.owner_id} ∪ workspace
  owner/admin members. No new table — manual config is intentionally not
  exposed in v1; the predicate is the extension seam.
- Front-end agent detail page distinguishes 403 (private agent the caller
  can't access) from 404 (deleted/missing) and renders a "no access"
  placeholder with a back-to-agents button.
- Go tests cover the pure predicate matrix + the four protected surfaces;
  vitest passes for the affected views.

Co-authored-by: multica-agent <github@multica.ai>

* feat(agents): gate issue assignment with the private-agent predicate

Refactor validateAssigneePair to call the shared canAccessPrivateAgent
helper. This closes the back door where a plain member could assign a
private agent to an issue and let normal task dispatch run it, side-
stepping the chat / @-mention gate. Agent callers (X-Agent-ID) bypass
so A2A delegation onto a private assignee still works.

Add an integration test covering all three callers (workspace owner,
agent owner, plain member).

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): close three private-agent gate bypasses found in PR review

1. X-Agent-ID forgery (resolveActor): require X-Task-ID alongside
   X-Agent-ID before trusting the agent identity. Without this a plain
   workspace member could set X-Agent-ID to any visible agent UUID and
   short-circuit the gate to "actor=agent, allow". Daemons already
   pair the two headers, so legitimate A2A traffic is unaffected.

2. Chat history read path (chat.go): GetChatSession / ListChatMessages /
   GetPendingChatTask / MarkChatSessionRead now go through a new
   gateChatSessionForUser helper that re-applies canAccessPrivateAgent
   after the ownership check, so a session creator whose role was later
   downgraded loses transcript access. ListChatSessions and
   ListPendingChatTasks filter their result sets by the same predicate.

3. Cross-workspace @mention (comment.enqueueMentionedAgentTasks):
   resolve the mentioned agent via GetAgentInWorkspace scoped to the
   issue's workspace so a UUID belonging to a different workspace's
   private agent can't slip past the gate (the gate was being applied
   against the current workspace's role table, which is the wrong
   one).

Regression tests cover each bypass, plus an update to the resolveActor
unit test to reflect the new "X-Agent-ID without X-Task-ID falls back
to member" contract.

Co-authored-by: multica-agent <github@multica.ai>

* test(handler): seed X-Task-ID alongside X-Agent-ID in existing agent-caller tests

After tightening resolveActor to require both headers (X-Agent-ID +
X-Task-ID) for the "agent" actor identity, three existing tests that
set only X-Agent-ID started failing because their requests now resolve
to "member" instead of "agent". Add createHandlerTestTaskForAgent
helper and seed a task per agent-caller assertion. Also patch
TestAgentExplicitMentionStillTriggers — it still passed only because
the @mention path doesn't care about author type for member callers,
but the test claims to exercise the agent path, so make it faithful.

Co-authored-by: multica-agent <github@multica.ai>

* test(handler): finish X-Task-ID seeding + fix cross-workspace mention test schema

The previous CI run still failed in two places:

1. server/cmd/server integration tests — postCommentAsAgent → authRequestWithAgent
   only set X-Agent-ID, so resolveActor downgraded the request to "member"
   and the on_comment chain produced the wrong task counts. Fix:
   authRequestWithAgent now also sets X-Task-ID, fetched or seeded by a new
   ensureAgentTask(agentID) helper.

2. TestMentionAgent_RejectsCrossWorkspaceAgentUUID's hand-crafted comment
   INSERT was missing comment.workspace_id, which migration 025 made
   NOT NULL. Pass testWorkspaceID into the seed row.

Build + vet clean locally; both packages compile.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 12:39:45 +08:00
Bohan Jiang
b2b20b291b fix(inbox): re-fire scroll-to-comment effect once issue finishes loading (#2332)
When clicking an inbox notification for a different issue, the IssueDetail
remounts and both the issue detail and timeline queries fetch in parallel.
If the timeline query resolves first, `timeline.length` flips to >0 while
`loading` is still true — at that moment the component is rendering the
skeleton, so `getElementById('comment-<id>')` returns null and the scroll
silently fails. Without `loading` in the effect's deps, the effect never
re-runs when the issue finally loads, leaving the user at the top of the
issue instead of jumping to the highlighted comment.

Add `loading` to the early-return guard and to the dep list so the scroll
fires once both the issue and its comments are mounted. The dropped
`return () => clearTimeout(timer)` was inside requestAnimationFrame and
never functioned as cleanup — removed for clarity.

Test seeds the timeline cache and holds back the issue fetch to reproduce
the race deterministically; without the fix the regression test times out
waiting for scrollIntoView.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 11:45:12 +08:00
jiawen134
00415de463 feat(editor): render mermaid diagrams inside issue descriptions (#2297)
* feat(editor): render mermaid diagrams inside issue descriptions

Issue descriptions are rendered through the Tiptap-based ContentEditor
(not ReadonlyContent), so the mermaid handler that PR #1888 added to
ReadonlyContent never reached them. Comments worked because comment-card
toggles between ContentEditor (edit mode) and ReadonlyContent (display
mode); issue descriptions stay in ContentEditor permanently.

This patch teaches the Tiptap CodeBlock NodeView to render a Mermaid
preview when the language is `mermaid`, giving issue descriptions a
split view: live diagram on top, editable source below. Theme variables
(light/dark), the sandboxed iframe, the lightbox and error fallback all
come from the existing implementation — only the location moved.

Changes:
- Extract MermaidDiagram + helpers (theme detection, sandbox iframe,
  lightbox, useThemeVersion) from `readonly-content.tsx` into a new
  `editor/mermaid-diagram.tsx`. ReadonlyContent (~200 lines lighter)
  imports the same component, so comment-card / inbox rendering is
  unchanged byte-for-byte.
- Update `code-block-view.tsx` (the Tiptap CodeBlock NodeView) to render
  `<MermaidDiagram>` above the editable source whenever the block's
  language is `mermaid` and the source is non-empty.

Tested:
- pnpm --filter @multica/views typecheck — clean
- pnpm --filter @multica/views test — 327 tests pass (43 files)
- Manually verified a mermaid block in an issue description renders as
  an SVG flowchart while staying editable underneath.

Closes #2079

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(editor): debounce mermaid preview re-renders during edits

Addresses review feedback on #2297. Previously every keystroke in a
Mermaid code block triggered `mermaid.initialize() + render()` on the
CodeBlockView preview. Because `mermaid.initialize()` mutates a
process-global config, those bursts could race a concurrent
ReadonlyContent render (e.g. a comment card) and clobber its theme
variables.

200ms is short enough that the preview still feels live during typing
but long enough to make concurrent inits unlikely in practice. The
ReadonlyContent path is unchanged: chart there is the saved markdown
and never changes after mount, so the race only existed on the new
edit-time path this PR introduced.

A small `useDebouncedValue` hook local to the file gates `chart` so
that it only flows into MermaidDiagram after 200ms of stable input.
When the language is non-Mermaid the hook short-circuits to "", so
non-Mermaid blocks pay no extra cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:11:20 +08:00
Jiayuan Zhang
448e75ce53 feat(issues): inline status & assignee pickers + batch select on sub-issue rows
- Sub-issue rows on the parent issue's detail page now expose inline StatusPicker and AssigneePicker, optimistically syncing the children cache via a useUpdateIssue parent-id fallback that scans loaded children caches.
- Hover-revealed checkbox + indeterminate select-all in the section header drive batch selection through the existing useIssueSelectionStore; the BatchActionToolbar gains a "placement" prop and renders inline directly under the sub-issues header so the action is right next to the rows.
- useBatchUpdateIssues / useBatchDeleteIssues now mirror their optimistic patches into every loaded children cache (with rollback) and invalidate children + childProgress on settle.
- SubIssueRow restructure: AppLink wraps only the identifier + title, so the checkbox / picker areas no longer accidentally fire navigation.

Refs MUL-2005.
2026-05-09 17:52:22 +02:00