Compare commits

..

60 Commits

Author SHA1 Message Date
Jiayuan Zhang
3c61a0e888 Merge branch 'main' into agent/lambda/bb798d97 2026-05-13 15:59:35 +02: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
Multica Eve
bdb66c2ce1 fix: update squad test fixtures (#2545)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 21:51:59 +08: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
Jiayuan Zhang
bc25de8b3a 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>
2026-05-13 19:48:57 +08:00
Jiayuan Zhang
285c3fb3ca 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>
2026-05-13 19:39:00 +08: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
8f40a61f8b fix(paths): add squads to consistency-test expected set (unblock CI) (#2538)
#2505 (Squad MVP) added paths.workspace(slug).squads() / squadDetail()
to paths.ts but didn't update paths/consistency.test.ts, whose first
test enumerates ALL parameterless workspace route methods and compares
the actual Set to an explicit expected Set. Squads landed on main, the
test started flagging the unexpected extra entry, and the @multica/core
test job has been red since 29082f7c.

Add "squads" to both:
- the expected-routes Set in `exposes the expected parameterless
  workspace route methods` (the test that was failing)
- the expected-segments array in `each parameterless route emits
  /{slug}/{segment}` (was silently skipping squads, now covered)

Also extend paths.test.ts with `ws.squads()` / `ws.squadDetail("sq_1")`
expectations so the per-route smoke test mirrors the rest of the
parameterless routes.

No source changes — only test files. The squad routes themselves
already exist on main and match the test's expectations.
2026-05-13 18:56:58 +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
Multica Eve
abfe33f350 docs: add May 13 changelog (#2529)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:40:13 +08:00
Bohan Jiang
26924dcc98 fix(desktop): restore Multica icon + WM_CLASS on Linux (MUL-2145) (#2525)
Closes the regression reported in https://github.com/multica-ai/multica/issues/2515 that
PR #2437 only half-fixed in v0.2.31.

Two gaps remained on Ubuntu/GNOME:

1. The .deb shipped only the source 1024×1024 PNG under
   /usr/share/icons/hicolor/, with no usable smaller sizes. GNOME's hicolor
   lookup walks 16…512 and falls back to the theme default when none
   match, so the launcher had no icon. The auto-generation pass in
   electron-builder silently produced only the source size for us. Drop
   pre-rendered 16/24/32/48/64/128/256/512 PNGs into build/icons/ and
   point `linux.icon` at the directory so packaging stops depending on
   the toolchain re-running that generation correctly.

2. WM_CLASS at runtime was `@multica/desktop`, while the .desktop file
   declared `StartupWMClass=Multica`. PR #2437 assumed Electron derives
   WM_CLASS from electron-builder.yml's `productName`, but Electron
   reads `app.getName()`, which reads the *packaged ASAR's* package.json
   — productName if present, otherwise name. Our source
   apps/desktop/package.json had no top-level productName, so the ASAR
   carried only `name: "@multica/desktop"` and Chromium emitted that as
   WM_CLASS, breaking the .desktop association and the dock icon.

   Fixed in two anchors for belt-and-braces: add
   `"productName": "Multica"` to apps/desktop/package.json (so the ASAR
   carries it and app.getName() resolves correctly by default), and call
   `app.setName("Multica")` in the production branch alongside the
   existing dev-only setName so a future regression in package.json or
   the build pipeline cannot silently re-break WM_CLASS.

The `StartupWMClass: Multica` declaration in electron-builder.yml stays
pinned and the surrounding comment has been rewritten to record the
correct WM_CLASS derivation.

Verification on a real Ubuntu install:
- `dpkg-deb -c multica-desktop-*-linux-amd64.deb | grep hicolor` lists
  ≥8 sizes.
- `xprop WM_CLASS` on the running window prints `"multica", "Multica"`.
- Launcher and dock both show the Multica logo with no manual
  ~/.local/share/icons workaround.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:31:52 +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
5db96b4007 fix(daemon): bypass Gemini folder-trust gate in headless mode (#2516) (#2523)
Gemini CLI's folder-trust feature throws FatalUntrustedWorkspaceError
(exit code 55) when the current workspace isn't in
`~/.gemini/trustedFolders.json` and the process is headless — no
interactive trust prompt is available. The daemon spawns gemini with
`-p` + `--yolo` in a freshly checked-out worktree that the user has
never trusted interactively, so every run with `security.folderTrust`
enabled fails after ~10s with exit status 55 and no useful output.

Default `GEMINI_CLI_TRUST_WORKSPACE=true` on the child env to short-
circuit `checkPathTrust` in gemini-core. This mirrors gemini-cli's
documented `--skip-trust` flag; the env var has been gemini's
documented headless escape hatch for the entire folder-trust feature
lifetime so the fix works on every gemini version that can produce
the crash. Callers that explicitly set the same key in cfg.Env win,
preserving the ability to opt back into the gate.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:05:12 +08:00
Bohan Jiang
178cfb5008 fix(daemon): strip Windows chcp noise from runtime version (#2516) (#2521)
The gemini CLI's Windows shim emits `Active code page: 65001` (from
`chcp`) to stdout before the real version reaches `--version` output.
The daemon stored the raw concatenation as the runtime version, so the
runtime detail page rendered `Active code page: 65001 0.42.0` instead
of `0.42.0`.

Scan `<cli> --version` line by line and return the first line carrying
a semver-shaped token. Full strings like `2.1.5 (Claude Code)` or
`codex-cli 0.118.0` survive unchanged; unparseable output falls back to
the trimmed raw value.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 16:58:14 +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
Bohan Jiang
384ddcbe65 fix(execenv): seed user-installed Codex skills into per-task CODEX_HOME MUL-1626 (#2519)
* fix(execenv): seed user-installed Codex skills into per-task CODEX_HOME

Codex is the only daemon runtime whose HOME is redirected — the daemon
sets CODEX_HOME to a per-task isolated directory so each task gets a
clean config slate without polluting ~/.codex/. Side effect: the codex
CLI never sees the user's `~/.codex/skills/` and tells the user no skill
was found.

Other runtimes (claude / copilot / opencode / pi / cursor / kimi / kiro)
don't have this issue: they leave HOME untouched and discover both
user-level skills (from ~/.<runtime>/skills) and workspace-assigned
skills (written to a workdir-local dotfile dir) natively. Codex is the
outlier.

Fix: in execenv.Prepare and execenv.Reuse, copy each subdirectory under
`~/.codex/skills/` into the per-task `codex-home/skills/` before writing
workspace-assigned skills. Workspace skills still win on sanitized-name
conflict; user-level installer symlinks (lark-cli style) are followed so
the per-task home gets real content rather than dangling links.

Closes #1922

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

* fix(execenv): wipe per-task codex skills dir before each hydration

Without this, the Reuse path leaves two classes of stale state behind:

1. Round 1 seeded user skill `writing/drafts/stale.md`. Round 2 reuses
   the same workdir with workspace skill `Writing` assigned: seed
   stage skips user `writing` (reserved), workspace stage writes
   `SKILL.md` via MkdirAll + WriteFile but never clears the directory,
   so the round-1 user support files surface under the workspace
   skill — violating "workspace fully wins on name conflict" and
   potentially leaking user-level files into a workspace skill view.

2. User uninstalls a skill from ~/.codex/skills between two runs. The
   prior copy in codex-home/skills/<name>/ lingers, so the codex CLI
   keeps seeing the removed skill.

Fix: RemoveAll(codex-home/skills) at the start of hydrateCodexSkills,
then re-seed user skills and re-write workspace skills. On Prepare
this is a no-op (envRoot was already wiped); on Reuse it resets the
slate.

Added two regression tests covering both scenarios.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 16:35:03 +08:00
Qiang Zhang
6a48022123 fix(desktop): prevent tab close router sync loop (#2393) 2026-05-13 16:34:48 +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
e8c2855746 fix(chat): collapse chat-done flicker via inline cache write (#2509)
* fix(chat): collapse chat-done flicker via inline cache write

The chat panel flickered at end-of-turn: live TimelineView unmounted →
short blank + scroll jump → persistent AssistantMessage finally appeared.

Root cause: chat:done's WS handler called setQueryData(pendingTask, {})
synchronously while invalidateQueries(messages) was an async refetch.
The render guard pendingAlreadyPersisted (chat-message-list.tsx:62-68)
expected the persisted message to already be in the messages cache
before pending cleared, but the sync/async ordering broke that guard.

Fix follows TkDodo's "combine setQueryData (active query) + invalidate
(others)" pattern. ChatDonePayload now carries the freshly-persisted
ChatMessage (id, content, elapsed_ms, created_at); the WS handler
writes it into chatKeys.messages BEFORE clearing pending. Same render
tick → AssistantMessage mounts before TimelineView unmounts → no
flicker. invalidate(messages) stays as a fallback for clients that
took the older code path or for content drift (redaction, etc.).

Also slim task:completed's chat branch — chat:done already wrote the
message and cleared pending; task:completed only refreshes the
cross-session pending aggregate that drives the FAB.

Field additions are all `omitempty` / TS `?:` so older clients ignore
them and older servers (no fields populated) fall back to invalidate-
only, preserving prior behavior.

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

* test(chat): cover chat done cache handoff

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>
Co-authored-by: Eve <eve@multica-ai.local>
2026-05-13 15:27:44 +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
6e371c2233 fix(docs): use dotenv code block lang to unblock Vercel build (#2508)
Shiki's default bundle doesn't include the `env` grammar, so MDX
prerendering fails with `Language `env` is not included in this
bundle.` The two pages added in #2474 used ```env, which broke both
Preview and Production deployments of multica-docs.

Swap the language tag to `dotenv` (Shiki ships it by default) — same
visual result, no Shiki config change needed.

Refs MUL-2122

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 13:26:13 +08:00
Multica Eve
ff27142b69 fix: treat empty output on successful completion as completed, not blocked (#2507)
When an agent completes successfully (exit 0) but produces no text
output, the daemon incorrectly classified it as 'blocked'. This is
wrong — agents can legitimately complete work via tool calls (posting
comments, pushing code) without emitting text output.

Change the empty-output path to return status=completed so the task
is correctly reported as successful.

Fixes MUL-2104

Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 12:56:17 +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
yihong
0ef48797ae docs: cloud link is 404 since changed (#2478)
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
2026-05-12 19:21:01 +08:00
Bohan Jiang
eca36fac84 fix(github): plumb GITHUB_APP_SLUG / GITHUB_WEBHOOK_SECRET through self-host (#2482)
The GitHub App integration code reads these two env vars and only enables
the Connect flow when both are set. .env.example never listed them, and
docker-compose.selfhost.yml did not forward them into the backend
container, so self-hosters following the integration docs had no working
way to turn the feature on.

MUL-2107

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 18:40:17 +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
Multica Eve
a0c64aaf65 docs: add 0.2.31 changelog (#2476)
* docs: add 0.2.31 changelog

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

* docs: refine 0.2.31 changelog copy

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

* docs: rename github integration changelog title

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 17:40:18 +08:00
Bohan Jiang
2e4d6aa3a9 docs(integrations): add GitHub PR ↔ issue integration feature page and self-host setup (MUL-2090) (#2474)
- New /github-integration page (EN + zh) covering identifier matching, merge → Done rule, limitations, and full self-host walkthrough (GitHub App fields, env vars, migration, curl probe)
- Adds Integrations nav section in meta.json + meta.zh.json
- Adds GITHUB_APP_SLUG / GITHUB_WEBHOOK_SECRET to environment-variables (EN + zh) with cross-link
- Cross-links from self-host quickstart Next steps

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:47:47 +08:00
Bohan Jiang
a02e58b488 fix(github): only auto-close issue after all linked PRs resolve (#2470)
* fix(github): only auto-close issue when all linked PRs have resolved

Previously, the webhook handler unconditionally moved an issue to `done`
as soon as a single linked PR was merged. If a second PR was also linked
to the same issue and still open / draft, the issue would close before
the work was actually finished.

Add `CountOpenSiblingPullRequestsForIssue` and gate the auto-status
transition on it: a merged PR advances its linked issues only when no
sibling PR linked to the same issue is still in flight. Issues stay put
while siblings are open or draft, and the merge that resolves the last
in-flight PR is the one that closes the issue.

Adds an integration test that opens two PRs against the same issue,
merges the first, asserts the issue stays in_progress, then merges the
second and asserts the issue advances to done.

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

* fix(github): re-evaluate auto-close on closed-without-merge events too

GPT-Boy review on #2470: gating only the `state == "merged"` branch left
one ordering hole. PR-A merges first → issue stays in_progress because
PR-B is open; PR-B later closes WITHOUT merging → no event ever re-runs
the auto-close check, so the issue is stuck in_progress.

Generalise the trigger to every terminal PR event (`merged` or `closed`)
and advance the issue only when:
- the issue is not already terminal (done / cancelled);
- no sibling PR is still in flight (open / draft);
- at least one linked PR — current or sibling — actually merged.

Rule (3) preserves "user closed every PR without merging → leave the
issue alone": if no work was delivered, the user decides what to do.

Replace `CountOpenSiblingPullRequestsForIssue` with
`GetSiblingPullRequestStateCountsForIssue`, which returns both the
in-flight count and the merged count in a single roundtrip.

Adds `TestWebhook_ClosedSiblingAfterMerge` (the regression GPT-Boy
flagged) and `TestWebhook_AllClosedWithoutMerge` (the negative case
guarding rule 3). Refactors the multi-PR webhook helper out of the
existing two-merge test so all three multi-PR scenarios share it.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:39:55 +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
Bohan Jiang
f08b2b4f50 fix(attachments): harden local sidecar serving and tighten Upload gate (#2459)
Follow-ups to #2444:

- ServeFile refuses keys ending in .meta.json so the sidecar JSON isn't
  a stable read API. Sits before any disk work so a crafted
  .meta.json sibling can't trigger an out-of-tree read.
- ServeFile rejects paths that resolve outside uploadDir (via
  filepath.Rel) before readLocalMeta runs. http.ServeFile's own ..
  guard fires later on r.URL.Path, but readLocalMeta would otherwise
  do a stray disk read on <some-path>.meta.json before the 400 lands.
- Upload only writes a sidecar when filename is non-empty. ServeFile
  only reads the filename anyway, so a content-type-only sidecar was
  dead disk weight.
- Drop the dead json.Marshal error branch — marshaling two strings
  cannot fail.

Three new tests cover sidecar suffix rejection, the traversal guard,
and the no-filename Upload short-circuit.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 12:49:22 +08:00
Truffle
91bdec9a54 fix(attachments): preserve original filename on /uploads/* downloads (#2444)
LocalStorage.ServeFile delegated straight to http.ServeFile without
setting Content-Disposition, so downloads of local-storage attachments
landed on disk under the UUID-based storage key instead of the human
filename the uploader had chosen. The S3 backend already sets
Content-Disposition on PutObject (s3.go:186-187), so the local backend
was the only one losing the original filename — a sibling asymmetry
that's been there since multi-backend support landed.

Upload now writes a sidecar <key>.meta.json beside the data file
capturing the original filename and sniffed content type. ServeFile
reads the sidecar when present and sets Content-Disposition using the
existing sanitizeFilename + isInlineContentType helpers, mirroring the
S3 inline/attachment decision exactly. Uploads from before this lands
have no sidecar and fall through to the previous behavior. Delete now
removes the sidecar alongside the data file so the upload directory
doesn't grow orphans.

Closes #2442
2026-05-12 12:37:07 +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
b58567ed6c fix(desktop): restore Multica app icon on Linux (#2437)
Fixes three gaps in the Linux desktop build that combined to render the
Multica window with the system Settings (gear) icon on Ubuntu:

1. Force `linux.executableName: multica` so the scoped npm name
   `@multica/desktop` stops leaking into `executableName`, the `.desktop`
   filename, the `Icon=` field, and `/usr/share/icons/hicolor/*/apps/*.png`.
   The leading `@` in the previously-generated `@multicadesktop` violates
   freedesktop desktop-entry naming, breaking GNOME's window↔.desktop
   association and forcing the theme-default icon. (The artifact-filename
   side of the same scoped-name leak was already patched in 10618b1f;
   this commit closes the desktop/icon-identity side.)

2. Always set `BrowserWindow({ icon })` on Linux — previously gated on
   `is.dev`. AppImage direct-launches never install the `.desktop` entry,
   so without an explicit window icon the WM has no other path to the
   bundled image. The resolved path now points into `app.asar.unpacked/`
   (matching the existing `bundledCliPath()` convention in
   `daemon-manager.ts`) since the Linux native icon code path requires a
   real filesystem path, not an asar-internal one.

3. Pin `linux.desktop.entry.StartupWMClass: Multica` explicitly. The
   value already matches the productName-derived default, so this is a
   build-time no-op today, but it makes the WM_CLASS↔StartupWMClass
   matching contract auditable in config — future changes to
   `productName` or `app.setName()` now show up as a diff against this
   file instead of silently re-breaking the icon association.

Fixes https://github.com/multica-ai/multica/issues/2424.
2026-05-11 23:51:44 +08:00
Bohan Jiang
bb312002d1 docs(self-hosting): document Caddy WebSocket essentials (#2436)
* docs(self-hosting): document Caddy WebSocket essentials

Add a single-domain Caddy example and harden the separate-domain one
with the WebSocket route a self-hoster actually needs:

- handle /ws* (prefix match, not exact `/ws`) so future path variants
  don't fall through to the frontend block
- flush_interval -1 inside the WS reverse_proxy, otherwise frames sit
  behind Caddy's default flush window and surface as "comments only
  appear after a page refresh"

Both gaps were hit by a self-hosted user on a single-domain Caddy
deployment, and neither was documented.

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

* docs(self-hosting): tighten Caddy /ws matcher to avoid catching `/ws-*` slugs

Use a named matcher `path /ws /ws/*` instead of the over-broad `handle /ws*`.
Caddy's `*` is a path-glob without segment boundary, so `/ws*` would also
match unrelated paths like `/ws-foo` — which is a legitimate workspace URL
under the current reserved-slug rules (only the exact `ws` slug is reserved).

Per GPT-Boy review on PR #2436.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 23:36:58 +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
Bohan Jiang
b7cd7e9adf docs(changelog): add 0.2.30 release notes for 2026-05-11 (#2416)
Summarizes the 24 PRs landed since v0.2.29 in EN and ZH changelog
data, organized into features, improvements, and fixes.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 18:51:12 +08:00
284 changed files with 22469 additions and 1050 deletions

View File

@@ -101,6 +101,13 @@ ALLOWED_ORIGINS=
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → Integrations "Connect GitHub")
# Both must be set for the Connect button to enable and for webhooks to be
# accepted; leave empty to disable the integration. See docs/github-integration.
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

View File

@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**

View File

@@ -20,7 +20,7 @@
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**

View File

@@ -186,16 +186,47 @@ In production, put a reverse proxy in front of both the backend and frontend to
### Caddy (Recommended)
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
```
multica.example.com {
# WebSocket route — must come before the catch-all
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
# Everything else → frontend
reverse_proxy localhost:3000
}
```
**Separate-domain layout** — frontend and backend on different hostnames:
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
reverse_proxy localhost:8080
}
```
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
### Nginx
```nginx

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -32,6 +32,45 @@ mac:
dmg:
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
linux:
# Override the Linux executable name to avoid leaking the scoped npm
# package name (`@multica/desktop`) into the installed binary, the
# `.desktop` file, and the hicolor icon filename. Without this override
# electron-builder defaults `executableName` to the package `name`,
# which after slash-stripping becomes `@multicadesktop` — producing
# `/usr/share/applications/@multicadesktop.desktop`,
# `Icon=@multicadesktop`, and
# `/usr/share/icons/hicolor/*/apps/@multicadesktop.png`. The leading `@`
# violates the freedesktop desktop-entry naming guidance, so GNOME /
# Ubuntu fail to associate the running window with the `.desktop` entry
# and fall back to the theme's default app icon (the Settings gear on
# Yaru). Forcing `multica` makes every Linux identity slot agree and
# matches `StartupWMClass=Multica` (productName-derived).
executableName: multica
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
# ASAR's `package.json` — `productName` if present, otherwise `name`.
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
# directly; it does not. With our source package.json carrying only
# `name: "@multica/desktop"`, packaged Electron emitted
# `WM_CLASS=@multica/desktop`, which broke association with this entry
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
# outside this file — `productName: "Multica"` on the source
# package.json (so the ASAR carries it) and `app.setName("Multica")`
# in the production branch of `src/main/index.ts` (belt-and-braces).
# Keep `StartupWMClass: Multica` pinned here so any future drift in
# those two anchors shows up as a diff against this declaration.
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
# window prints `Multica` for both fields.
desktop:
entry:
StartupWMClass: Multica
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
# auto-generation silently shipped only the 1024×1024 source in our
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
# with no usable size and falling back to the theme default. Shipping
# the sizes from source removes the toolchain dependency entirely.
icon: build/icons
target:
- AppImage
- deb

View File

@@ -10,10 +10,11 @@ export default [
globals: { ...globals.node },
},
},
// Security: every renderer-controlled URL that reaches the OS shell must
// flow through openExternalSafely in src/main/external-url.ts (scheme
// allowlist). Enforce it statically so a direct shell.openExternal call
// cannot silently regress the protection.
// Security: every renderer-controlled URL that reaches the OS shell or the
// native download system must flow through the safe wrappers in
// src/main/external-url.ts (scheme allowlist). Enforce it statically so
// direct shell.openExternal / webContents.downloadURL calls cannot silently
// regress the protection.
{
files: ["src/main/**/*.ts"],
rules: {
@@ -25,6 +26,12 @@ export default [
message:
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
},
{
selector:
"CallExpression[callee.object.property.name='webContents'][callee.property.name='downloadURL']",
message:
"Do not call webContents.downloadURL directly. Use downloadURLSafely from './external-url' so the http/https allowlist stays enforced.",
},
],
},
},

View File

@@ -1,5 +1,6 @@
{
"name": "@multica/desktop",
"productName": "Multica",
"version": "0.1.0",
"private": true,
"description": "Multica Desktop — native desktop client for the Multica platform.",

View File

@@ -1,4 +1,4 @@
import { shell } from "electron";
import { shell, type BrowserWindow } from "electron";
// True when the URL parses and uses http/https — the only schemes we let
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
@@ -19,6 +19,19 @@ export function openExternalSafely(url: string): Promise<void> | void {
return shell.openExternal(url);
}
// Canonical wrapper around webContents.downloadURL. All renderer-controlled
// URLs that trigger a native download MUST flow through here; direct calls
// to `webContents.downloadURL` elsewhere in the main process are banned by
// the no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
// Reuses the same http/https allowlist as openExternalSafely.
export function downloadURLSafely(win: BrowserWindow, url: string): void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked downloadURL: ${describeScheme(url)}`);
return;
}
win.webContents.downloadURL(url);
}
function getHttpProtocol(url: string): "http:" | "https:" | null {
try {
const { protocol } = new URL(url);

View File

@@ -5,16 +5,31 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// by the `is.dev` branch below.
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
// Bundled icon used for dock/taskbar branding. macOS/Windows production
// builds let the OS pick up the icon from the .app bundle / .exe resources,
// but Linux production needs an explicit BrowserWindow `icon` — AppImage
// direct-launch doesn't register the .desktop entry, so GNOME has no path
// from the running window to the hicolor icon and falls back to the
// theme default. Consumed in createWindow() (all platforms in dev, Linux
// in prod) and the macOS dev dock branch.
//
// `asarUnpack: resources/**` in electron-builder.yml extracts the icon to
// `app.asar.unpacked/`, but `__dirname` resolves into `app.asar/`. The
// Linux native window-icon code path expects a real filesystem path
// (unlike Electron's nativeImage loader which transparently reads from
// asar), so swap the segment — same pattern as bundledCliPath() in
// daemon-manager.ts. In dev `__dirname` has no `app.asar`, so the replace
// is a no-op.
const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace(
"app.asar",
"app.asar.unpacked",
);
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
@@ -106,13 +121,39 @@ function createWindow(): void {
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option in
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
// Windows/Linux pick up the window/taskbar icon from this option.
// On macOS it's ignored (dock comes from app.dock.setIcon below).
// Linux production needs this explicitly because AppImage direct-launch
// does not install a .desktop entry, so the WM has no other path to
// the bundled icon; without it Ubuntu falls back to the theme default.
...(is.dev || process.platform === "linux"
? { icon: BUNDLED_ICON_PATH }
: {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
// Required for the Chromium PDF viewer (PDFium) to activate inside
// iframes — used by the attachment preview modal for application/pdf
// files. Default is false in Electron; without it <iframe src=*.pdf>
// renders blank.
//
// Security trade-off, accepted intentionally:
// 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 (see useDownloadAttachment); user-supplied URLs
// are routed through `setWindowOpenHandler` → `openExternalSafely` and
// cannot land in this renderer.
// 3. Chromium's PDFium plugin is itself sandboxed inside its own process
// and only handles the `application/pdf` MIME — it does not expose
// Flash, Java, or other historical plugin surfaces.
//
// If we ever tighten `webSecurity` / `sandbox`, revisit this by hosting
// the PDF viewer in a dedicated BrowserView with `plugins: true` scoped
// to that view, keeping the main renderer plugin-free.
plugins: true,
additionalArguments: [`--multica-locale=${systemLocale}`],
},
});
@@ -192,6 +233,14 @@ const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
if (is.dev) {
app.setName(DEV_APP_NAME);
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
} else {
// Pin the production app name in code. Electron's Linux WM_CLASS is set
// from app.getName() when the first BrowserWindow is realized; the
// packaged ASAR's package.json `productName` already steers app.getName()
// to "Multica", but anchoring it here makes WM_CLASS ↔ StartupWMClass
// (declared in electron-builder.yml) survive a regression in
// productName / the build pipeline. Must run before requestSingleInstanceLock().
app.setName("Multica");
}
// --- Protocol registration -----------------------------------------------
@@ -251,7 +300,7 @@ if (!gotTheLock) {
// so the Canary dev build is visually distinct from a stock Electron
// run. `app.dock` is macOS-only — guard the call.
if (is.dev && process.platform === "darwin" && app.dock) {
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
const icon = nativeImage.createFromPath(BUNDLED_ICON_PATH);
if (!icon.isEmpty()) app.dock.setIcon(icon);
}
@@ -268,6 +317,14 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
ipcMain.handle("file:download-url", (_event, url: string) => {
if (!mainWindow) {
console.warn("[download] ignored file:download-url — mainWindow torn down");
return;
}
downloadURLSafely(mainWindow, url);
});
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
// preload can attach the values to `desktopAPI.appInfo` before any renderer
// code reads them, ensuring the very first HTTP request from the renderer

View File

@@ -19,6 +19,9 @@ interface DesktopAPI {
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Download a file by URL through Electron's native download system.
* Shows a native save dialog. On non-desktop platforms this is undefined. */
downloadURL: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */

View File

@@ -89,6 +89,11 @@ const desktopAPI = {
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Download a file by URL through Electron's native download system.
* Shows a save dialog and saves to disk. Unlike openExternal, this
* avoids browser rendering of HTML files on Linux.
* On non-desktop platforms this property is undefined. */
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),

View File

@@ -14,11 +14,13 @@ import { AgentDetailPage } from "./pages/agent-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
@@ -145,7 +147,18 @@ export const appRoutes: RouteObject[] = [
element: <AgentDetailPage />,
handle: { title: "Agent" },
},
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
{
path: "squads/:id",
element: <SquadDetailPageView />,
handle: { title: "Squad" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "usage",
element: <DashboardPage />,
handle: { title: "Usage" },
},
{
path: "settings",
element: (

View File

@@ -180,6 +180,61 @@ describe("useTabStore actions", () => {
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("defers disposing the closed tab router until after the store update", () => {
vi.useFakeTimers();
try {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
const closingTab = useTabStore
.getState()
.byWorkspace.acme.tabs.find((t) => t.id === closedTabId);
const dispose = vi.mocked(closingTab!.router.dispose);
store.closeTab(closedTabId);
expect(dispose).not.toHaveBeenCalled();
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
vi.runAllTimers();
expect(dispose).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("ignores router-sync updates from a tab after it has been closed", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
store.closeTab(closedTabId);
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(closedTabId, { path: "/acme/runtimes", icon: "Monitor" });
store.updateTabHistory(closedTabId, 1, 2);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
});
it("does not replace the tab group for no-op router-sync updates", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tab = useTabStore.getState().byWorkspace.acme.tabs[0];
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(tab.id, { path: tab.path, icon: tab.icon, title: tab.title });
store.updateTabHistory(tab.id, tab.historyIndex, tab.historyLength);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");

View File

@@ -350,7 +350,10 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const closing = group.tabs[index];
closing.router.dispose();
const disposeClosingRouter = () => {
// Let React unmount the tab's RouterProvider before disposing it.
window.setTimeout(() => closing.router.dispose(), 0);
};
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
@@ -363,6 +366,7 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
disposeClosingRouter();
return;
}
@@ -378,6 +382,7 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
disposeClosingRouter();
},
setActiveTab(tabId) {
@@ -402,6 +407,13 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, ...patch };
if (
next.path === current.path &&
next.title === current.title &&
next.icon === current.icon
) {
return;
}
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
@@ -418,6 +430,12 @@ export const useTabStore = create<TabStore>()(
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
if (
current.historyIndex === historyIndex &&
current.historyLength === historyLength
) {
return;
}
const next: Tab = { ...current, historyIndex, historyLength };
const nextTabs = [...group.tabs];
nextTabs[index] = next;

View File

@@ -160,6 +160,7 @@ Chinese term reference:
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |

View File

@@ -160,6 +160,7 @@ Multica 的产品名词分两类:
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |

View File

@@ -141,6 +141,22 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
**Leaving `FRONTEND_ORIGIN` unset creates two silent failures**: (1) invite email links point at `https://app.multica.ai` (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to `localhost:3000 / 5173 / 5174`, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
</Callout>
## GitHub integration
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations install button URL |
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
**Behavior when either is unset:**
- `Connect GitHub` in Settings → Integrations is **disabled** and shows a "not configured" hint to admins.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
## Usage analytics
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
@@ -154,5 +170,6 @@ By default, the server reports to Multica's official PostHog instance. To opt ou
## Next
- [Sign-in and signup configuration](/auth-setup) — how to actually configure the auth-related variables above and where the traps are
- [GitHub integration](/github-integration) — how to set up the GitHub App that backs `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET`
- [Troubleshooting](/troubleshooting) — symptoms and fixes for common misconfigurations
- [Daemon and runtimes](/daemon-runtimes) — what the `MULTICA_DAEMON_*` parameters actually do

View File

@@ -141,6 +141,22 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**`FRONTEND_ORIGIN` 不设就有两个静默失败**1邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例2WebSocket 连接的 Origin 校验回落到 `localhost:3000 / 5173 / 5174`,生产部署的 WebSocket 全部被拒,前端看起来「实时更新不工作」。
</Callout>
## GitHub 集成
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
**任一变量未设时:**
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
## 用量统计
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
@@ -154,5 +170,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
## 下一步
- [登录与注册配置](/auth-setup) —— 上面 auth 相关的那几个环境变量怎么真的配、陷阱在哪
- [GitHub 集成](/github-integration) —— `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET` 背后的 GitHub App 怎么建
- [故障排查](/troubleshooting) —— 配错了常见的症状和修复
- [守护进程与运行时](/daemon-runtimes) —— `MULTICA_DAEMON_*` 参数的行为含义

View File

@@ -337,16 +337,47 @@ In production, put a reverse proxy in front of both the backend and frontend to
### Caddy (Recommended)
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
```
multica.example.com {
# WebSocket route — must come before the catch-all
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
# Everything else → frontend
reverse_proxy localhost:3000
}
```
**Separate-domain layout** — frontend and backend on different hostnames:
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
reverse_proxy localhost:8080
}
```
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
### Nginx
```nginx

View File

@@ -0,0 +1,183 @@
---
title: GitHub integration
description: Connect a GitHub App once, then PRs whose branch, title, or body reference an issue identifier auto-attach to that issue — and merging the PR moves the issue to Done.
---
import { Callout } from "fumadocs-ui/components/callout";
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
There is no per-issue setup. The whole flow is identifier-driven.
## What the integration does
| Surface | Behavior |
|---|---|
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
Only the PR itself is mirrored. Commits, branch refs without an open PR, and CI check states are **not** modeled. The integration is intentionally narrow.
## How identifiers are matched
The webhook extracts identifiers from three fields, in this order: **PR head branch**, **PR title**, **PR body**. The matcher is:
- Case-insensitive — `mul-123`, `MUL-123`, `Mul-123` all match.
- Bounded — a `\b` on the left and a digit anchor on the right keep it from grabbing version numbers like `v1.2-3` or email-style strings.
- Workspace-scoped — only matches the workspace's own [issue prefix](/workspaces). `FOO-1` in a workspace whose prefix is `MUL` is ignored, even if the integer matches another issue.
- Deduplicated — listing `MUL-1, MUL-1` in the body links the issue once.
You can reference **multiple issues** in one PR. `Closes MUL-1, MUL-2` links the PR to both, and merging it advances both to `Done`.
## The auto-merge-to-Done rule
When a PR's `merged` field flips to `true`, every linked issue is evaluated:
| Issue current status | Result |
|---|---|
| `done` | No change (already terminal). |
| `cancelled` | **No change** — cancelled means the user explicitly abandoned the work; the integration does not override that signal. |
| Anything else (`todo`, `in_progress`, `in_review`, `blocked`, `backlog`) | Moved to `done`. |
Closing a PR **without** merging it only updates the PR card's state to `Closed`. The linked issues stay where they were — the user is the one who decides what closing-without-merge means.
<Callout type="info">
The action is attributed to the `system` actor on the timeline. Subscribers of the issue receive an inbox notification for the status change, the same way they would if a human had moved it.
</Callout>
## What's not auto-linked
- **Identifiers in commit messages** — only branch / title / body are scanned. A commit titled `MUL-123: fix login` does not auto-link unless the same string also appears in the PR title or body.
- **Identifiers in PR comments** — only the PR's own metadata is scanned; later GitHub comments are ignored.
- **PRs in repos the App isn't installed on** — without the App, Multica never receives the webhook.
- **Manually linking a PR to an issue** — there is no UI for this yet. If your team's convention puts identifiers in a place Multica isn't reading, add them to the PR title or body.
## Disconnecting
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
## Permissions and visibility
- **Connect / disconnect** require workspace **owner or admin**. Members see the card description but no Connect button.
- The **Pull requests** sidebar on an issue is visible to anyone who can read the issue — same permissions as the rest of issue detail.
- The GitHub App requests **read-only** access to pull requests and metadata. Multica never pushes commits, comments, or status checks back to GitHub.
## Self-host setup
If you're running Multica on Multica Cloud, the integration is already configured — skip this section.
For self-host, you create one GitHub App, point it at your server, and set two environment variables. The whole flow is below.
### 1. Create a GitHub App
Go to one of:
- Personal account → `https://github.com/settings/apps/new`
- Organization → `https://github.com/organizations/<org>/settings/apps/new`
Fill in:
| Field | Value |
|---|---|
| **GitHub App name** | Anything recognizable, e.g. `Multica` or `Multica (staging)`. |
| **Homepage URL** | Your Multica frontend, e.g. `https://multica.example.com`. |
| **Callback URL** | Leave blank — Multica doesn't use OAuth user identity. |
| **Setup URL** | `https://<api-host>/api/github/setup`. **Check "Redirect on update"**. |
| **Webhook → Active** | Enabled. |
| **Webhook URL** | `https://<api-host>/api/webhooks/github`. |
| **Webhook secret** | Generate a long random string (e.g. `openssl rand -hex 32`). You'll paste the same value into Multica's env in step 2. |
| **Permissions → Repository → Pull requests** | **Read-only**. |
| **Permissions → Repository → Metadata** | Read-only (mandatory). |
| **Subscribe to events** | Tick **Pull request**. |
| **Where can this GitHub App be installed?** | Your choice. `Only on this account` is fine for single-org setups. |
After **Create GitHub App**, note two things from the App's detail page:
- The **public link** at the top — its tail is the slug. `https://github.com/apps/multica-acme` → slug = `multica-acme`.
- The **webhook secret** you just generated (you can't read it back from GitHub later — save it now).
<Callout type="warning">
**Webhook secret ≠ Client secret.** The App settings page has both fields stacked together. The **Webhook secret** is what signs `pull_request` payloads — that's the one Multica needs. The **Client secret** is for OAuth and is not used by this integration. Mixing them up produces a confusing `401 invalid signature` on every webhook delivery.
</Callout>
### 2. Set environment variables
On the API server:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
```
Both variables are required. If either is missing:
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings` after install.
Restart the API after setting the env vars.
### 3. Run migrations
The integration ships its tables in migration `079_github_integration`. If you're upgrading an older deployment:
```bash
make migrate-up
```
Three tables get created: `github_installation`, `github_pull_request`, `issue_pull_request`. They cascade-delete with their workspace, so removing a workspace cleans them up automatically.
### 4. Connect from the UI
In Multica:
1. Open **Settings → Integrations** as an owner or admin.
2. Click **Connect GitHub**. GitHub opens in a new tab.
3. Pick the repositories to grant access to and **Install**.
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
### 5. Verify with a curl probe
If GitHub's **Recent Deliveries** page reports `401 invalid signature` after install, the two sides have different secrets. The fastest way to find out which side is wrong is to bypass GitHub:
```bash
SECRET="<the value you put in GITHUB_WEBHOOK_SECRET>"
BODY='{"zen":"test"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -i -X POST https://<api-host>/api/webhooks/github \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Event: ping" \
-H "Content-Type: application/json" \
-d "$BODY"
```
| HTTP status | Meaning | Fix |
|---|---|---|
| `200` `{"ok":"pong"}` | Server's loaded secret matches your `$SECRET`. The mismatch is on GitHub. | Edit the App → Webhook secret → **paste the same value** → **Save changes** (clicking out of the field without Save keeps the old secret). Redeliver. |
| `401 invalid signature` | Server's loaded secret is **not** what you think it is. | Confirm the env var landed in the running process (e.g. `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" | wc -c`). Re-deploy. |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` is empty in the process. | Set the env var, restart the API. |
## Limitations
A few rough edges to be aware of today:
- **No manual link UI yet** — the only way to link a PR is to have the identifier in its branch, title, or body.
- **No CI / check state** — only the PR itself is mirrored. Build status, review comments, and reviewers are not surfaced in Multica.
- **No workspace-level config** for the merge → Done rule — it's a fixed default (`merged → done`, unless `cancelled`). Workspace-customizable mappings are a future addition.
- **Multi-PR-to-one-issue is conservative on merge** — if two PRs both reference `MUL-123` and the first one merges, the issue is moved to `Done` immediately. A follow-up change to wait for all linked PRs to resolve before advancing is in progress.
## Next
- [Issues](/issues) — the issue identifiers (`MUL-123`) referenced from PRs
- [Workspaces](/workspaces) — where the workspace-specific issue prefix is set
- [Environment variables](/environment-variables) — full env reference, including the GitHub variables above

View File

@@ -0,0 +1,183 @@
---
title: GitHub 集成
description: 一次性连接 GitHub App之后 PR 的分支名、标题或正文里写了 issue 编号(例如 MUL-123就会自动挂到那个 issue 上——PR 合并时 issue 自动转 Done。
---
import { Callout } from "fumadocs-ui/components/callout";
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时issue 自动转 **Done**。
没有 per-issue 的配置,整个流程是「编号驱动」的。
## 集成做了什么
| 出现位置 | 行为 |
|---|---|
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR含标题、仓库、状态`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
| **Webhook后台** | 每次 `pull_request` 事件触发upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
只镜像 PR 本身。Commit、没开 PR 的分支、CI 检查状态都**不**入库——集成有意保持窄边界。
## 编号是怎么匹配的
Webhook 从三个字段抽取编号,顺序是:**PR head 分支** → **PR 标题** → **PR 正文**。匹配规则:
- 大小写不敏感——`mul-123`、`MUL-123`、`Mul-123` 都能匹配
- 有边界——左侧 `\b`、右侧只接数字,避免误抓 `v1.2-3`、email 地址等
- 限定到本工作区——只匹配本工作区的 [issue prefix](/workspaces)。前缀是 `MUL` 的工作区里PR 出现 `FOO-1` 不会匹配,即使数字撞另一个 issue 也不会
- 自动去重——`Closes MUL-1, MUL-1` 只关联一次
一个 PR 里**可以同时引用多个 issue**。比如 `Closes MUL-1, MUL-2`PR 同时关联两个 issue合并时两个 issue 都会转 `Done`。
## Merge 自动转 Done 的规则
PR 的 `merged` 字段翻成 `true` 时,逐个评估关联的 issue
| Issue 当前状态 | 结果 |
|---|---|
| `done` | 不变(已经是终态)|
| `cancelled` | **不变**——cancelled 是用户明确放弃工作的信号,集成不覆盖 |
| 其他(`todo` / `in_progress` / `in_review` / `blocked` / `backlog`| 转成 `done` |
PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`issue 状态不变。"关闭但不合并"语义因团队而异Multica 不替用户做决定。
<Callout type="info">
状态变更的 actor 是 `system`。订阅了该 issue 的成员会收到 inbox 通知,和成员手动改状态时一致。
</Callout>
## 哪些情况不会自动关联
- **Commit message 里的编号**——只扫 PR 的分支 / 标题 / 正文。一个 commit message 写 `MUL-123: fix login` 不会触发关联,除非同样的字符串也出现在 PR 标题或正文里
- **PR 评论里的编号**——只扫 PR 自己的元数据,后续的 GitHub comment 不读
- **App 没安装的仓库里的 PR**——没 AppMultica 收不到 webhook
- **手动把 PR 关联到 issue**——暂时没有这个 UI。如果你们的约定把编号放到 Multica 不扫的地方,请改放到 PR 标题或正文里
## 断开连接
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR但来自这个 installation 的新 webhook 事件不再被接受。
## 权限和可见性
- **Connect / Disconnect** 需要工作区 **owner 或 admin**。普通成员能看到卡片描述但看不到 Connect 按钮
- **Pull requests** 侧栏对所有能看到该 issue 的成员可见——和 issue 详情页其他部分权限一致
- GitHub App 申请的是 PR 和 Metadata 的 **只读** 权限。Multica 从不向 GitHub 推 commit、评论或 status check
## Self-Host 配置
如果你在 Multica Cloud 上,集成已经配好——跳过本节。
Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境变量。完整流程如下。
### 1. 创建一个 GitHub App
到下面其中一个页面:
- 个人账号 → `https://github.com/settings/apps/new`
- 组织 → `https://github.com/organizations/<org>/settings/apps/new`
按下表填写:
| 字段 | 值 |
|---|---|
| **GitHub App name** | 任何能辨识的名字,例如 `Multica` 或 `Multica (staging)` |
| **Homepage URL** | 你的 Multica 前端,例如 `https://multica.example.com` |
| **Callback URL** | 留空——本集成不使用 OAuth 用户身份 |
| **Setup URL** | `https://<api-host>/api/github/setup`。**勾选 "Redirect on update"** |
| **Webhook → Active** | 启用 |
| **Webhook URL** | `https://<api-host>/api/webhooks/github` |
| **Webhook secret** | 生成一个长随机字符串(例如 `openssl rand -hex 32`)。这个值会同样填到 step 2 的 env 里 |
| **Permissions → Repository → Pull requests** | **Read-only** |
| **Permissions → Repository → Metadata** | Read-only必填|
| **Subscribe to events** | 勾选 **Pull request** |
| **Where can this GitHub App be installed?** | 自选。单组织部署建议选 `Only on this account` |
点 **Create GitHub App** 之后,从详情页记下两件事:
- 顶部 **public link** 的尾部即 slug。`https://github.com/apps/multica-acme` → slug = `multica-acme`
- 你刚生成的 **webhook secret**GitHub 之后不会再让你读取这个值——现在就保存好)
<Callout type="warning">
**Webhook secret ≠ Client secret。** App 设置页里两个字段紧挨着。**Webhook secret** 用于签 `pull_request` payload这才是 Multica 需要的那个;**Client secret** 是 OAuth 用的,和本集成无关。混淆这两个会得到「每条 webhook 都 `401 invalid signature`」的诡异症状。
</Callout>
### 2. 配置环境变量
API server 上:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
```
两个都必填。任何一个缺失:
- Settings 里 `Connect GitHub` 按钮会被 **disable**并显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
设完 env 重启 API。
### 3. 执行 migration
集成的表在 migration `079_github_integration` 里。如果是升级既有部署:
```bash
make migrate-up
```
会创建三张表:`github_installation`、`github_pull_request`、`issue_pull_request`。三张表都 cascade 跟随 workspace——删工作区会自动清理。
### 4. 在 UI 里连接
到 Multica
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
2. 点 **Connect GitHub**GitHub 在新 tab 打开
3. 选择要授权的仓库,点 **Install**
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
### 5. 用 curl 自检
如果 GitHub 的 **Recent Deliveries** 里第一次 PR 事件就报 `401 invalid signature`,说明两边的 secret 不一致。绕过 GitHub 直接测 server 是最快的定位方法:
```bash
SECRET="<你填给 GITHUB_WEBHOOK_SECRET 的值>"
BODY='{"zen":"test"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -i -X POST https://<api-host>/api/webhooks/github \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Event: ping" \
-H "Content-Type: application/json" \
-d "$BODY"
```
| HTTP 状态 | 含义 | 修法 |
|---|---|---|
| `200` `{"ok":"pong"}` | server 加载的 secret 和你 `$SECRET` 一致——GitHub 那边的 secret 才是错的 | 编辑 App → Webhook secret 字段**粘相同的值** → **必须点 Save changes**(不点 Save 等于没改)→ Redeliver |
| `401 invalid signature` | server 加载的 secret **不是**你以为的那个 | 进容器确认 env 实际生效(例如 `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`),重新部署 |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` 在进程里是空的 | 配上 env重启 API |
## 已知限制
目前还没做的几个边界:
- **手动 link UI 暂未提供**——关联 PR 的唯一方法是把 issue 编号写到 PR 分支 / 标题 / 正文
- **不读 CI / check 状态**——只镜像 PR 本身构建状态、reviewer 评论、reviewer 列表都没接进 Multica
- **没有工作区级别的 merge → status 映射配置**——默认固定是 `merged → done`cancelled 除外)。可配置映射是后续迭代
- **同 issue 多 PR 时merge 行为偏激进**——两个 PR 都引用 `MUL-123` 时,第一个 merge 就把 issue 转 Done。"等所有关联 PR 都解决再推进 issue 状态"的优化已经在做了
## 下一步
- [Issues](/issues) —— PR 引用的 issue 编号(`MUL-123`)的来源
- [工作区](/workspaces) —— 工作区 issue prefix 的设置位置
- [环境变量](/environment-variables) —— 完整 env 清单,包含上面提到的 GitHub 变量

View File

@@ -27,6 +27,8 @@
"autopilots",
"---Inbox---",
"inbox",
"---Integrations---",
"github-integration",
"---Self-hosting & ops---",
"environment-variables",
"auth-setup",

View File

@@ -26,6 +26,8 @@
"autopilots",
"---收件箱---",
"inbox",
"---集成---",
"github-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -115,5 +115,6 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
- [Environment variables](/environment-variables) — full env reference
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
- [GitHub integration](/github-integration) — connect a GitHub App so PRs auto-link to issues and merging closes them
- [Troubleshooting](/troubleshooting) — start here when things go wrong
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path

View File

@@ -114,5 +114,6 @@ multica setup self-host
- [环境变量](/environment-variables) —— 完整 env 清单
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
- [GitHub 集成](/github-integration) —— 连一个 GitHub App让 PR 自动关联 issue、merge 时自动转 Done
- [故障排查](/troubleshooting) —— 遇到问题先来这里
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 DesktopWeb 前端 + CLI 仍然是最快的自部署路径

View File

@@ -0,0 +1 @@
export { SquadDetailPage as default } from "@multica/views/squads";

View File

@@ -0,0 +1 @@
export { SquadsPage as default } from "@multica/views/squads";

View File

@@ -0,0 +1 @@
export { DashboardPage as default } from "@multica/views/dashboard";

View File

@@ -284,6 +284,90 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.32",
date: "2026-05-13",
title: "Usage Insights, Chat Renaming & Smoother Desktop Flows",
changes: [],
features: [
"Usage now shows workspace and project token activity, runtime trends, and per-agent rankings in one place",
"Chat sessions can be renamed directly from the chat header",
"Feedback reports can include screenshots or files so teams have the context they need",
],
improvements: [
"The Usage page has clearer naming and a more dynamic agent leaderboard",
"New chats and completed chat responses update more smoothly with fewer loading flashes",
"Self-hosted GitHub setup is easier to configure and the setup docs point to the right cloud URL",
"User-installed Codex skills are available automatically when new tasks run",
],
fixes: [
"Empty successful agent responses are marked completed instead of blocked",
"Pasted mentions in instruction editors keep their mention links",
"Desktop attachment downloads use the native Linux flow and tab closing no longer loops",
"Gemini and Windows runtime startup checks are more reliable in unattended runs",
"Long GitHub repository lists stay usable when adding project resources",
],
},
{
version: "0.2.31",
date: "2026-05-12",
title: "GitHub Integration, Chat Attachments & Safer Issue Navigation",
changes: [],
features: [
"Connect GitHub so linked pull requests appear on Multica issues, sync their status, and close the Multica issue automatically when the PR closes",
"Chat messages can include file attachments and image previews",
"Agents and runtimes can now be kept public or private for clearer team access",
"Stopping a single agent task now asks for confirmation before it is terminated",
"New GitHub integration docs cover both hosted and self-hosted setup",
],
improvements: [
"Issue links land more reliably on the exact comment or activity you opened",
"Long issue timelines scroll more smoothly",
"The feedback dialog now points contributors toward GitHub discussions and issues",
"Self-hosted Caddy guidance now calls out real-time connection requirements",
"Linux desktop packages show the Multica app icon again",
],
fixes: [
"Downloaded attachments keep their original filenames",
"Local attachments are served more reliably, and upload controls stay disabled until files are ready",
"Issue creation dialogs keep their text fields at the correct height",
"Runtime documentation links point to the correct page",
],
},
{
version: "0.2.30",
date: "2026-05-11",
title: "Mermaid in Issues, Per-Runtime Timezone & Workspace-Leave Runtime Revocation",
changes: [],
features: [
"Mermaid diagrams render inline in issue descriptions",
"Sub-issue rows gain inline status and assignee pickers, with batch select across rows",
"Per-runtime timezone for token-usage aggregation, so daily rollups respect your local day",
"Private agents are gated by an `allowed_principals` predicate, with fine-grained visibility",
"A member leaving or being removed from a workspace now revokes their runtimes automatically",
"Set custom per-token prices for unmaintained models so usage reflects real cost",
"Landing page header gains a Changelog link",
],
improvements: [
"Daemon self-heals when a runtime is deleted server-side — no more zombie local entries",
"Chat and comment composer share the same `Mod+Enter` send shortcut",
"Copilot CLI model catalog expanded with correct dotted IDs",
"Copilot failure details now surface in the UI instead of a generic error",
"Daemon brief is inlined into the system prompt for providers that need it",
"Realtime WebSocket accepts same-origin upgrades from mobile and CLI",
],
fixes: [
"Recent-issues list no longer leaks across workspaces",
"CloudFront attachment download URLs are re-signed at click time, fixing expired previews",
"Windows reply templates use `--content-file` across every provider so non-ASCII bodies survive",
"Daemon suppresses extra git console pop-ups on Windows",
"Pi extension tools are no longer filtered by a hardcoded `--tools` allowlist",
"Inbox scrolls to the target comment once the issue finishes loading",
"`autopilot create/update` accepts `--mode run_only`",
"Changelog header link styled to match the GitHub ghost button",
"OpenAI Codex / GPT model pricing populated — cost no longer shows $0",
],
},
{
version: "0.2.29",
date: "2026-05-09",

View File

@@ -284,6 +284,90 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.2.32",
date: "2026-05-13",
title: "用量洞察、聊天重命名与桌面体验优化",
changes: [],
features: [
"Usage 页面集中展示 workspace 和 project 的 token 使用、runtime 趋势和 agent 排名",
"聊天会话可以直接在聊天页顶部重命名",
"反馈时可以附带截图或文件,方便团队快速理解问题",
],
improvements: [
"Dashboard 更名为 Usage并加入更清晰的 agent 排行展示",
"新聊天和消息完成状态切换更顺,不再频繁闪加载状态",
"自托管 GitHub 配置更完整,文档里的云端链接也已修正",
"用户安装的 Codex Skills 会自动带入新的 agent 任务",
],
fixes: [
"没有输出内容但成功完成的 agent 任务会显示为 completed不再误判为 blocked",
"在指令编辑器中粘贴的 mention 会保留可点击链接",
"Linux 桌面端下载附件时走系统原生流程,关闭标签页也不再触发循环跳转",
"Gemini 和 Windows runtime 的启动检查更稳定,适合无人值守执行",
"添加项目资源时,较长的 GitHub 仓库列表可以正常滚动",
],
},
{
version: "0.2.31",
date: "2026-05-12",
title: "GitHub 集成、聊天附件与 Issue 定位优化",
changes: [],
features: [
"接入 GitHub 后,关联的 Pull Request 会显示在 Multica Issue 中,状态会同步到 Multica关闭 PR 后会自动关闭对应 Issue",
"聊天消息支持添加文件附件和图片预览",
"Agent 和 runtime 可以设置公开或私有,方便控制团队可见范围",
"停止单个 agent 任务前会先弹出确认,避免误操作",
"新增 GitHub 集成文档,覆盖托管版和自托管配置",
],
improvements: [
"打开 Issue 链接时,会更稳定地定位到指定评论或动态",
"很长的 Issue 时间线滚动更顺畅",
"反馈入口更明确地引导用户到 GitHub 参与讨论和反馈",
"自托管 Caddy 配置文档补充实时连接要求",
"Linux 桌面端安装包恢复显示 Multica 应用图标",
],
fixes: [
"下载附件时保留原始文件名",
"本地附件访问更稳定,上传按钮会等文件准备好后再可用",
"创建 Issue 弹窗里的文本框高度显示正确",
"Runtime 文档入口跳转到正确页面",
],
},
{
version: "0.2.30",
date: "2026-05-11",
title: "Issue 内 Mermaid、Runtime 时区聚合与离开 Workspace 自动吊销",
changes: [],
features: [
"Issue 描述内联渲染 Mermaid 图表",
"Sub-issue 行支持就地切换状态与 assignee并支持跨行批量选中",
"Token 用量按每个 runtime 自己的时区聚合,每日 rollup 与本地日期对齐",
"私有 Agent 通过 `allowed_principals` 判定可见性,权限粒度更细",
"成员离开或被移出 workspace 时,自动吊销其名下的 runtime",
"对未维护的模型支持自定义 token 价格,使用量真实反映成本",
"Landing 页面 header 加入 Changelog 入口",
],
improvements: [
"服务端删除 runtime 时daemon 端自我修复,不再留下僵尸条目",
"Chat 与评论输入框统一使用 `Mod+Enter` 发送",
"Copilot CLI 模型目录补齐正确的 dotted ID",
"Copilot 失败详情直接在 UI 中透出,不再只是一个通用错误",
"Daemon brief 直接内联进 system prompt针对需要的 provider 生效",
"Realtime WebSocket 放行同源升级,移动端与 CLI 可正常握手",
],
fixes: [
"Recent issues 列表不再跨 workspace 串扰",
"CloudFront 附件下载链接在点击时重新签名,过期预览的问题修复",
"所有 provider 的 Windows reply 模板改用 `--content-file`,非 ASCII 内容不再丢失",
"Daemon 抑制 Windows 上多余的 git 控制台弹窗",
"Pi 插件工具不再被硬编码的 `--tools` allowlist 过滤掉",
"Inbox 在 issue 加载完成后再滚动到目标评论",
"`autopilot create/update` 允许 `--mode run_only`",
"Changelog header 链接样式对齐 GitHub ghost button",
"OpenAI Codex / GPT 模型价格补齐,使用成本不再显示为 $0",
],
},
{
version: "0.2.29",
date: "2026-05-09",

View File

@@ -58,9 +58,11 @@ services:
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
restart: unless-stopped
frontend:

View File

@@ -0,0 +1,555 @@
# Agent 快速创建 — 三阶段实施计划
> Status: Draft (设计阶段,未动工)
> Owner: TBD
> Last updated: 2026-05-13
## TL;DR
- **目标**:降低用户创建 Agent 的门槛,从「手工填表 + 一个个挑 skill」演进到「一键模板」「AI 推荐 skill」「AI 直接创建 agent」三档
- **三阶段**:Template(必做、独立)→ Skill Finder(AI 推荐 skill)→ AI Create Agent(AI 直接创建)
- **架构关键**:Phase 2/3 复用现有 Quick-create Issue 基础设施(派任务给 agent + tool calling + inbox 通知),不引入新 LLM 调用路径
- **不需要新基础设施**:无 SSE、无 server-side LLM、无新 WS channel
- **soft blocker**:两处 routine 重构(`createSkillWithFiles` TX 拆分、skill 同名 find-or-create)
- **不做**:接入 Anthropic 官方 marketplace(plugin 体系跟单体 skill 形态不匹配)、接入 ClawHub(战略对位错误 + 实际使用率低,见 §5)
---
## 1. 背景与目标
### 1.1 当前现状
当前用户创建一个 Agent 需要走的步骤:
1.`/agents` 页面 → 点 "Create Agent"
2. 手工填 name / description / runtime / model
3. 手工写 instructions(空白文本框,用户自己思考措辞)
4. 创建完后进 Agent 详情页 → 点 "Add Skill" → 一个一个挑 skill 关联
5. 如果 workspace 还没有需要的 skill,得先去别处建/导入 skill(`POST /api/skills/import` 支持 skills.sh / GitHub / ClawHub 三种 URL)
**痛点**:
- 用户得**预先知道**自己需要哪些 skill,这要求他对 skill 生态熟悉
- 写 instructions 是空白文本编辑,大多数用户不知道写什么
- 跨多页操作,体感上"创建一个能用的 Agent"是个项目,不是个动作
### 1.2 三阶段方案
| Phase | 提供给用户的能力 | 是否需要 AI | 独立可发布 |
|---|---|---|---|
| **1. Template** | 选模板 → 自动 import 模板带的 skill + 预填 instructions | 否 | ✅ |
| **2. Skill Finder** | 描述需求 → AI 推荐 skill 列表 → 一键导入到 workspace | ✅ | ✅(独立功能,任何场景都能用) |
| **3. AI Create Agent** | 描述需求 → AI 自己 find skill + 写 instructions + 创建 agent | ✅ | 依赖 Phase 2 |
每个 phase **本身有用户价值**,不需要等下一个 phase 才能用:
- Phase 1 用户能用模板创建 agent,即使后两阶段没做
- Phase 2 用户能在任何地方"用 AI 找 skill"(创建 agent 时、给现有 agent 加 skill 时、单纯逛 skill 时)
- Phase 3 是 1+2 的组合
### 1.3 不在范围内
明确不做的事(及理由,见 §5):
- 接入 Anthropic 官方 plugin marketplace(`anthropics/claude-plugins-official`)
- 接入 ClawHub 的"发现/搜索"层(import 路径已经存在,但是死代码,建议下线)
- 让 AI 直接装 skill 到用户本地 `~/.claude/skills/`(npx skills CLI 行为)
- Server-side LLM 调用(后端目前没有 LLM SDK,这条路引入新基础设施,而 Quick-create 模式可以避开)
---
## 2. 关键概念回顾
> 这一节给没参与前期讨论的同事看。已经熟悉 skill 系统的可跳到 §3。
### 2.1 Skill 是什么
Skill 是一个**按需加载的能力包**,本质是 SKILL.md 文件 + 可选附件。Anthropic 2025-12 把它发布为开放标准(agentskills.io),Cursor / OpenAI / GitHub Copilot 等都已采纳——同一份 SKILL.md 跨多个 agent 工具都能用。
每个 runtime(Claude Code / Cursor / Codex 等)启动时**自动扫**自己约定的目录(`~/.claude/skills/``.cursor/skills/` 等),读 SKILL.md 的 frontmatter 形成"我手上有这些 skill"的清单注入 system prompt。具体 skill 正文只在被触发时才进 context。
### 2.2 Multica 的 Skill 数据模型
3 张表(migration `008_structured_skills.up.sql`):
| 表 | 关键字段 |
|---|---|
| `skill` | `id, workspace_id, name, description, content (=SKILL.md 正文), config (含 origin 元数据)` |
| `skill_file` | `skill_id, path, content`(SKILL.md 的附件,如 examples/*.md、scripts/*.py) |
| `agent_skill` | `agent_id, skill_id`(M:N 关联) |
**关键约束**:`UNIQUE(workspace_id, name)` — 同 workspace 内 skill 名字必须唯一。
### 2.3 Skill 流转链路(数据库 → runtime)
任务运行时,skill 从 PG 到 runtime 的完整路径:
```
1. 数据库:skill + skill_file + agent_skill 三张表的行
2. Daemon claim 任务:
POST /api/runtimes/{runtimeId}/tasks/claim
handler/daemon.go:1018-1098 (ClaimTaskByRuntime)
→ service/task.go:1447-1463 (LoadAgentSkills)
→ 把 agent 关联的所有 skill 全文塞进 HTTP 响应
3. Daemon 算工作目录:
server/internal/daemon/execenv/execenv.go:114, 124
workDir = {WorkspacesRoot}/{wsID}/{shortTaskID}/workdir
4. Daemon 按 runtime 算 skill 目录:
server/internal/daemon/execenv/context.go:121-158 (resolveSkillsDir)
claude → {workDir}/.claude/skills
cursor → {workDir}/.cursor/skills
codex → 特殊:{codexHome}/skills
5. Daemon 把字符串写成磁盘文件:
context.go:175-204 (writeSkillFiles)
核心就两行 os.WriteFile
6. Daemon 启动 runtime,cwd = workDir
runtime 自己扫 .claude/skills/(等)→ 加载 frontmatter
7. 任务结束:os.RemoveAll(workDir)
PG 是真相源,workDir 是每次任务临时复印件
```
**核心 invariant**:Multica 不教 runtime 怎么用 skill,只把文件摆到 runtime 已经会扫的位置。
### 2.4 Template = Instructions + Skill 引用
Template 是个**静态 JSON 定义**,包含:
- 预写好的 instructions
- 一组 skill 引用(用 URL 指向 skills.sh / GitHub)
用户选模板时,后端:
1. 对每个 skill 引用,**复用现有 `/api/skills/import` 的 fetcher**(`fetchFromSkillsSh` / `fetchFromGitHub`)拉内容
2. 物化到 workspace(同名复用 / 新建)
3. CreateAgent + setAgentSkills
4. 整个流程一个事务
skill 引用为什么用 URL 而不是内联 SKILL.md 内容:
- 复用现有 import 基础设施,零新代码
- skill 内容跟 GitHub 同步,不需要 vendoring 进 multica 仓库
- 模板 JSON 体积小,git review 友好
### 2.5 Quick-create Issue 模式(Phase 2/3 复用的基础设施)
当前 `POST /api/issues/quick-create`(handler/issue.go:877-982)的流程:
```
1. 后端 enqueue 任务:
- agent_task_queue 加一行,issue_id = NULL,context JSONB = {type: "quick-create", prompt: ...}
- 立即返回 202 Accepted + task_id
2. Daemon claim 任务时识别 quick-create:
- 检查 task.Context != nil AND !task.IssueID.Valid
- 解析为 QuickCreateContext (service/task.go:1810-1811)
3. Daemon 构造 prompt:
- daemon/prompt.go:45-106 (buildQuickCreatePrompt)
- 把用户的自然语言 prompt 作为语义核心
- 加上"调用 multica issue create CLI 命令"的指令
4. Agent 跑 LLM + tool calling:
- LLM 输出形如 `multica issue create --title="..." --description="..."` 的命令
- daemon 执行 CLI 命令,CLI 调 POST /api/issues 创建 issue
- CLI 自动在请求里带上 MULTICA_QUICK_CREATE_TASK_ID env(daemon/daemon.go:2081)
→ 让创建出来的 issue 带 origin_type='quick_create' + origin_id=<task_id>
5. 后端 link + 通知:
- 完成检测:GetIssueByOrigin(workspace_id, "quick_create", task_id)
- LinkTaskToIssue(task_id, issue_id) 把任务行的 issue_id 补上
- 写 inbox_item 通知用户(notifyQuickCreateCompleted, service/task.go:1908-1920)
```
**关键洞察**:这个模式**完全通用化**了。复用它只需要:
1. 新的 context JSONB type(比如 `"skill-find"``"agent-create"`)
2. 新的 prompt builder
3. 新的"完成检测 + inbox 通知"
不需要任何 daemon / 任务队列层面的改动。
---
## 3. 三阶段详细设计
### Phase 1:Agent Template
**目标**:用户选模板 → 一键得到一个可用的 agent(自带 skill + instructions),不需要 AI 参与。
#### 设计
- **Template 定义存放**:静态 JSON,commit 在 `server/internal/agenttmpl/templates/*.json`
- **Template JSON 形态**:
```json
{
"slug": "code-reviewer",
"name": "Code Reviewer",
"description": "审代码用的 agent",
"instructions": "你审代码,关注 N+1 查询、错误处理、类型安全...",
"skills": [
{ "source_url": "https://skills.sh/obra/superpowers/tdd" },
{ "source_url": "https://github.com/foo/bar/tree/main/skills/code-style" }
]
}
```
- **新 endpoint**:`POST /api/agents/from-template`
- 请求:`{template_slug, name, runtime_id, ...overrides}`
- 后端流程(**全部在一个事务里**):
1. 加载 template JSON
2. 对每个 skill source_url:
- 调用 `detectImportSource(url)`(skill.go:586-617)分发到对应 fetcher
- 通过 GetSkillByWorkspaceAndName 检查 workspace 是否已有同名 skill
- 有 → 复用现有 skill_id
- 无 → 调 `createSkillWithFilesInTx`(待重构,见 §4)物化
3. `CreateAgent`(复用 agent.go:CreateAgent 的内部逻辑)
4. 批量 `AddAgentSkill` 关联
- 响应:`{agent: {...}, imported_skill_ids: [...], reused_skill_ids: [...]}`
- **前端**:`CreateAgentDialog`(packages/views/agents/components/create-agent-dialog.tsx)加 "From template" 模式,跟现有 manual / duplicate 模式并列
- 模板选择器 → 预览(instructions + skill 列表)→ 提交调新 endpoint
- 响应里的 `reused_skill_ids` 用 toast 提示"以下 skill 已存在,沿用了 workspace 现有版本"
#### 起步模板清单(初版,可调)
- `code-reviewer` — 代码审查
- `tdd-pair` — TDD 配对编程
- `db-reviewer` — 数据库 / SQL 审查
- `pr-summarizer` — PR 摘要
- `docs-writer` — 文档撰写
具体每个模板选哪些 skill URL,在 Phase 1 启动时单独决定(需要逛 skills.sh 选高质量 skill)。
#### Phase 1 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/agenttmpl/`(新包) | 加载 JSON 模板的代码 |
| `server/internal/agenttmpl/templates/*.json`(新文件) | 5 个起步模板 |
| `server/internal/handler/agent.go` | 新 handler `CreateAgentFromTemplate` |
| `server/internal/handler/skill_create.go` | **重构**:拆出 `createSkillWithFilesInTx` 变体(见 §4) |
| `server/pkg/db/queries/skill.sql` | 加 `GetSkillByWorkspaceAndName`(见 §4) |
| `server/cmd/server/router.go` | 注册新 endpoint |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 template 模式 |
| `packages/core/api/agent.ts` | 加 `createAgentFromTemplate` API 调用 |
| `packages/views/agents/components/template-picker.tsx`(新文件) | 模板选择器组件 |
### Phase 2:Skill Finder
**目标**:用户用自然语言描述需求(如"我想审 SQL"),AI 推荐一组 skill,用户勾选一键导入到 workspace。
#### 设计
- **架构选型**:走 quick-create 模式,**不是后端直接调 LLM**
- **新 endpoint**:`POST /api/skills/find`
- 请求:`{prompt, agent_id}`(agent_id 是用来跑这个 LLM 任务的 agent,跟 Quick-create Issue 一样要求预先有 agent)
- 后端流程:
1. enqueue 任务:`agent_task_queue` 加一行,context JSONB = `{type: "skill-find", prompt}`
2. 返回 202 + task_id
- **Daemon prompt builder**:`daemon/prompt.go` 加 `buildSkillFindPrompt`(类比 buildQuickCreatePrompt)
- 喂给 agent 的 prompt 大致:
```
用户需求:{user_prompt}
你的任务:从以下 curated skill 清单里选 3-5 个最相关的推荐给用户。
可选 skill 清单(JSON):
{curated_skill_index}
输出:调用 `multica skill find --output-results '<JSON>'` 命令,
JSON 形态为 [{name, description, source_url, reason}, ...]
```
- **CLI 命令**(新):`multica skill find --output-results <JSON>`
- 不发起 HTTP 请求,只把 JSON 写到 daemon 通过 env 指定的临时文件
- daemon 读这个文件,把内容塞进 inbox notification 的 payload
- **Curated skill 索引**:`server/internal/agenttmpl/skill_index.json`(新文件)
- 几十到上百条精选 skill,每条:`{name, description, source_url, tags, install_count}`
- 维护方式:工程师/产品手工维护,代码 review 卡内容质量
- MVP **不做**实时 GitHub Code Search 或 skills.sh 爬虫
- **完成通知**:写 inbox_item,type = `skill_find_done`,payload 含推荐结果数组
- **前端**:
- 独立"Find Skill"页面(`/skills/find` 或 `/skills?ai=true`)
- skill list page 上"用 AI 找 skill"按钮入口
- 用户输入 prompt → 提交 → 等通知 → inbox item 里展示 skill 卡片(name + description + source_url + reason)
- 用户勾选 → 一键批量调现有 `POST /api/skills/import`(每个 skill 一次,可考虑加 batch endpoint 但 MVP 不必要)
#### Phase 2 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/skill.go` | 新 handler `FindSkill`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueSkillFindTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildSkillFindPrompt` |
| `server/internal/daemon/daemon.go` | 加 `SkillFindContext` 识别 + env 注入 |
| `server/cmd/multica/cmd_skill.go` | 加 `find --output-results` 子命令 |
| `server/internal/agenttmpl/skill_index.json`(新文件) | curated 清单 |
| `packages/views/skills/components/find-skills-dialog.tsx`(新文件) | UI |
| `packages/core/api/skill.ts` | 加 `findSkills` API |
| `packages/views/inbox/items/skill-find-result.tsx`(新文件) | inbox item 渲染 |
### Phase 3:AI Create Agent
**目标**:用户描述需求,AI 自己 find skill + 写 instructions + 创建 agent。
#### 设计
- **架构选型**:走 quick-create 模式,**组合 Phase 2 的 find 能力 + 新的 agent create CLI**
- **新 endpoint**:`POST /api/agents/ai-draft`
- 请求:`{prompt, host_agent_id}`(host_agent_id 是跑这个元任务的 agent)
- 后端:enqueue 任务,context = `{type: "agent-create", prompt}`,返回 202 + task_id
- **Daemon prompt builder**:`buildAgentCreatePrompt` 指挥 agent 三步走:
```
1. 调用 `multica skill find --output-results ...` 选 skill
(或直接看 curated 清单选)
2. 基于选定 skill 写 instructions
3. 调用 `multica agent create --name ... --instructions ... --skill-ids ...`
创建 agent 并关联 skill
```
- **CLI 命令**(新):`multica agent create`
- 后端 handler 已存在(handler/agent.go:CreateAgent),只需要绑 CLI(~50 行)
- 创建时带 `MULTICA_AI_DRAFT_TASK_ID` env,服务端用它做 origin 标记 + LinkTaskToAgent
- **完成通知**:inbox_item type = `agent_draft_done`,payload 含 agent_id + 摘要
- **前端**:`CreateAgentDialog` 加 "AI" 模式
- 输入需求 → 提交 → 等通知 → inbox 通知里点击 → 跳新 agent 详情页(用户在那儿编辑/调整)
#### Phase 3 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/agent.go` | 新 handler `AIDraftAgent`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueAgentDraftTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildAgentCreatePrompt` |
| `server/cmd/multica/cmd_agent.go` | 加 `create` 子命令(handler 已有) |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 "AI" 模式 |
| `packages/core/api/agent.ts` | 加 `aiDraftAgent` API |
| `packages/views/inbox/items/agent-draft-result.tsx`(新文件) | inbox item 渲染 |
---
## 4. Blocker 清单与修复方案
### 4.1 [SOFT] `createSkillWithFiles` 不可组合事务
**问题**:`server/internal/handler/skill_create.go:21-71` 这个函数自己 `Begin()` 一个事务,执行完 `Commit()`。Phase 1 需要在外层事务里**多次**调用它(import N 个 skill + createAgent + setAgentSkills 都在一个 TX),但现在没法这么用。
**影响范围**:Phase 1
**修复方案**:
```go
// 拆成两个函数(保持原 API 向后兼容):
// 新增:接受外部 qtx,不管事务
func createSkillWithFilesInTx(
ctx context.Context,
qtx *db.Queries,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
// 不 Begin/Commit,只调 qtx.CreateSkill + qtx.UpsertSkillFile loop
}
// 改造:原函数变成包装层,内部调 InTx 版
func (h *Handler) createSkillWithFiles(
ctx context.Context,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
tx, _ := h.TxStarter.Begin(ctx)
defer tx.Rollback()
qtx := h.Queries.WithTx(tx)
result, err := createSkillWithFilesInTx(ctx, qtx, input)
if err != nil { return nil, err }
tx.Commit()
return result, nil
}
```
旧调用方完全不变。Phase 1 新 endpoint 自己 Begin,然后多次调 `*InTx` 变体,最后统一 Commit。
**工作量**:小(< 100 行重构)
### 4.2 [SOFT] Skill 同名冲突
**问题**:`skill` 表有 `UNIQUE(workspace_id, name)` 约束。Phase 1 模板导入时,如果模板里的 skill 跟 workspace 已有 skill 同名,INSERT 会报 PG 错误 23505,整个 from-template 流程挂掉。
**影响范围**:Phase 1
**修复方案**:加 find-or-create 模式:
1. 新 query `GetSkillByWorkspaceAndName`(`server/pkg/db/queries/skill.sql`)
2. Phase 1 流程改成:
- 对每个模板 skill,先查 workspace 是否已有同名
- 有 → 复用现有 skill_id,跳过 import
- 无 → 调 `createSkillWithFilesInTx` 物化
3. 响应里返回 `reused_skill_ids: [...]`,前端 toast "以下 skill 已存在,沿用现有版本"
**不选择"覆盖"或"加后缀"的原因**:用户可能已经改过本地版本,覆盖会丢用户修改;加后缀污染 skill 列表。
**工作量**:小(< 50 行 + 1 条 sqlc query)
### 4.3 [SOFT] 缺 `multica skill find` CLI
**影响范围**:Phase 2
**方案**:加一个 CLI 子命令,模仿 `multica skill import` 的实现(`server/cmd/multica/cmd_skill.go:55-60, 323-357`)。**注意**:这个命令不发 HTTP 请求,只是 LLM agent 用来"输出推荐结果"的 channel——它把 LLM 推荐的 JSON 写到 daemon 指定的临时文件,daemon 读完塞进 inbox notification。
**工作量**:小(~80 行)
### 4.4 [SOFT] 缺 `multica agent create` CLI
**影响范围**:Phase 3
**方案**:后端 handler 已有(`handler/agent.go:CreateAgent`),只需在 `server/cmd/multica/cmd_agent.go` 加 `create` 子命令。
**工作量**:小(~50 行)
### 4.5 [非 blocker] System Agent 问题
**之前误判为 hard blocker,实际不是**:
Quick-create Issue 当前的设计就要求用户**预先有一个 agent** 才能用——AI 路径不为"零 agent 起步"服务。Phase 2/3 沿用这个前提,所以**新 workspace 没 agent 时 AI 功能不可用**是符合现有产品模型的,不需要 bootstrap 一个 system agent。
产品自然解锁路径:
1. 新用户进 workspace
2. 用 **Phase 1 Template**(无需 AI、无需现有 agent)创建第一个 agent
3. 之后 Phase 2/3 即可用,host_agent 就用刚创建的那个
---
## 5. 关键设计决策(及理由)
### 5.1 为什么不接 Anthropic 官方 marketplace?
**结构错配**。Anthropic 官方 marketplace(`anthropics/claude-plugins-official`)是 **plugin 体系**:每个 plugin 是个 bundle,包含 `.claude-plugin/plugin.json` + `skills/` + `agents/` + `hooks/` + `.mcp.json`。
Multica 只有**单体 skill**(SKILL.md + skill_file),没有 plugin / bundle 概念。要接入得新写 plugin parser + 拆分逻辑,工作量大,而 skills.sh 已经覆盖了同一批高质量内容(skills.sh 后端就是 GitHub raw,绝大多数 skill 作者就在 GitHub 上,Anthropic plugin 体系里的 skill 通常也在作者的 GitHub repo 里有单体副本)。
### 5.2 为什么走 quick-create 模式而不是后端直接调 LLM?
代码事实:`server/` 目前**完全没有任何 LLM SDK**(grep `anthropic-sdk-go` / `openai-go` / 任何 LLM provider 都是 0 命中)。所有 LLM 调用都通过 daemon → runtime → CLI 这条路。
走 quick-create 模式的优势:
- **不引入新基础设施**(SSE / LLM client / API key 管理)
- **复用 agent 的 instructions / model / runtime 配置**(用户已经在某个 agent 里配置过的偏好自动生效)
- **统一计费 / 用量监控**(LLM 调用都计在用户 agent 的 quota 里)
代价:
- 用户得**预先有一个 agent**(参见 §4.5,这跟 Quick-create Issue 现状一致)
- LLM 调用通过 daemon 多一跳,延迟略增(但不阻塞 202 响应)
### 5.3 为什么 Skill Finder 是 endpoint 不是 SKILL.md?
**Skill Finder 名字里的 "Skill" 是它的产物(找的是 skill),不是它自己实现成 SKILL.md**。
如果做成 SKILL.md 文件:
- 它得装进某个 agent 里才能用 → 单点功能变得需要前置配置
- skill 教 agent 调什么?调 `npx skills`(装到本地,目标错)?调 Multica API(那要写 tool channel,绕一大圈)
- AI 创建 Agent(Phase 3)那条路要"启动 agent → agent 调 skill → skill 调 tool",链路复杂三倍
做成 endpoint:
- 用户独立可用(独立 UI 入口)
- AI 创建 Agent 后端直接调 endpoint,两个功能共用一段逻辑
- 简单
### 5.4 Curated Skill 索引 vs 实时搜索
**MVP 用 curated 清单**(几十条精选 URL + 摘要 commit 在 repo 里)。理由:
- 质量可控
- 不踩 GitHub Code Search rate limit
- 不被 LLM 编 URL(LLM 知识 cutoff + hallucinate URL 是真问题)
- 维护成本低
进阶可加 `search_skills(query)` tool 实时打 GitHub Code Search,等用户反馈"清单太窄"再做。
### 5.5 不做 ClawHub(顺手清理建议)
**现状**:`POST /api/skills/import` 当前支持 3 个 source(`fetchFromClawHub` skill.go:642-744、`fetchFromSkillsSh` skill.go:757-879、`fetchFromGitHub` skill.go:1363-1463)。ClawHub 是个独立 HTTP 客户端,不复用 GitHub 基础设施。
**判断**(详见之前讨论):
- ClawHub 服务的是 OpenClaw 平台(Multica 同生态位竞品的内容生态)
- UI 没有发现/搜索层,用户只能粘 URL,而 ClawHub 装机量远低于 skills.sh,用户主动逛的概率极低
- 独立代码路径,API 演进时单独跟进
**建议**(独立于本计划,可以一起做也可以延后):
- 跑 `SELECT count(*) FROM skill WHERE config->'origin'->>'type' = 'clawhub'` 看实际使用量
- 接近 0 → 渐进下线(先去 UI SourceCard,后续 release 删 fetcher)
- 有量 → 留着,但仍不为它做新功能
---
## 6. 实施依赖与排期
```
[Phase 1] Template
└── 独立,无依赖
└── 包含 2 个 soft blocker 的修复(§4.1 §4.2)
[Phase 2] Skill Finder
└── 依赖 Phase 1 中的 skill import 路径(已存在,沿用)
└── 含 1 个 soft blocker(§4.3)
[Phase 3] AI Create Agent
└── 依赖 Phase 2(复用 find skill 能力)
└── 含 1 个 soft blocker(§4.4)
```
**真实排期建议**:
- Phase 1 可单独发版,有独立价值
- Phase 2 独立可发版(找 skill 是高频独立场景)
- Phase 3 等 Phase 2 ready 后开始
每个 phase 启动时单独开 PR 设计 doc,本文档只是路线图。
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| GitHub rate limit(模板 import 多个 skill 时) | 已有 `GITHUB_TOKEN` env 支持(skill.go:1163-1166),5000/h 配额够用。生产环境确保配置 |
| 模板里引用的 skill repo 被作者删除 | from-template handler 容错:某个 skill fetch 失败 → 整个事务回滚,前端展示具体哪个 URL 挂了。模板自己也定期 review |
| LLM 推荐编造 URL(Phase 2) | 用 curated 清单作为 context,**不让 LLM 自由发挥 URL**,推荐范围限定在清单内 |
| Phase 3 LLM 写出离谱 instructions | 用户在 inbox 通知里点击 → 跳新 agent 详情页**编辑模式**,不直接进入"已就绪"状态。用户必须确认 |
| 模板格式后续要演进(加字段) | Template JSON 加 `version` 字段,后端按 version 兼容老格式 |
| Curated skill 清单过时(作者改 repo / 删 skill) | 加 CI 任务定期跑一遍清单 URL,挂掉的报警通知维护者 |
---
## 8. 不在本文档范围(已识别的下一步话题)
- 跨 workspace 模板共享 / marketplace 化(用户能把自己的 agent 存成模板分享)
- 实时 GitHub Code Search tool(Phase 2 进阶)
- Server-side LLM 调用基础设施(如果未来需要 streaming 等场景)
- ClawHub 下线决策(独立讨论,见 §5.5)
- Skill 版本管理(workspace skill 版本号 / 升级提示)
---
## 附录 A:代码索引
> 给接手开发的同事的快速参考。每条 file:line 都在本计划里被引用过,记录在这里方便跳转。
| 主题 | 位置 |
|---|---|
| Skill DB 模型 | `server/migrations/008_structured_skills.up.sql:4-32` |
| Skill 创建 handler + 事务 | `server/internal/handler/skill.go:143-162` + `skill_create.go:21-71` |
| Skill import 入口(支持 3 个 source) | `server/internal/handler/skill.go:1538` |
| Skill import source 分发 | `server/internal/handler/skill.go:586-617` (`detectImportSource`) |
| Skills.sh fetcher | `server/internal/handler/skill.go:757-879` (`fetchFromSkillsSh`) |
| GitHub fetcher | `server/internal/handler/skill.go:1363-1463` (`fetchFromGitHub`) |
| ClawHub fetcher | `server/internal/handler/skill.go:642-744` (`fetchFromClawHub`) |
| Agent 创建 handler | `server/internal/handler/agent.go:380-399` (request) + `:422-564` (CreateAgent) |
| Agent 创建 sqlc | `server/pkg/db/queries/agent.sql:19-25` |
| Agent-Skill 关联 sqlc | `server/pkg/db/queries/agent.sql:86-103` |
| 当前 Agent Duplication(前端模式) | `packages/views/agents/components/agents-page.tsx:286-301`(post-create skill copy) |
| Agent 创建 dialog | `packages/views/agents/components/create-agent-dialog.tsx` |
| Skill add dialog | `packages/views/agents/components/skill-add-dialog.tsx` |
| Quick-create Issue handler | `server/internal/handler/issue.go:877-982` (`QuickCreateIssue`) |
| Quick-create task enqueue | `server/internal/service/task.go:488+` (`EnqueueQuickCreateTask`) |
| Daemon claim + load skills | `server/internal/handler/daemon.go:1018-1098` + `service/task.go:1447-1463` |
| Daemon prompt build | `server/internal/daemon/prompt.go:17-36` (dispatch) + `:45-106` (`buildQuickCreatePrompt`) |
| Daemon execenv prepare | `server/internal/daemon/execenv/execenv.go:103-176` |
| Skill 目录约定(runtime mapping) | `server/internal/daemon/execenv/context.go:121-158` (`resolveSkillsDir`) |
| Skill 文件落盘 | `server/internal/daemon/execenv/context.go:175-204` (`writeSkillFiles`) |
| Quick-create 完成检测 + inbox | `server/internal/service/task.go:1810-1949` |
| LinkTaskToIssue | `server/internal/handler/agent.go:97-105` |
| Quick-create Issue 前端 modal | `packages/views/modals/quick-create-issue.tsx:48-570+` |
| Multica CLI 入口 | `server/cmd/multica/main.go:62-79` |
| Skill CLI 命令 | `server/cmd/multica/cmd_skill.go:17-96`(已有 import,无 find) |
| Agent CLI 命令 | `server/cmd/multica/cmd_agent.go:101-112`(已有 list/get,无 create) |

View File

@@ -0,0 +1,170 @@
/**
* E2E: chat attachment upload + send back-fills the message link.
*
* Stays at the HTTP layer (auth → upload-file → send-chat-message → DB
* check) so the test doesn't depend on a real agent runtime being online.
* The UI wiring is covered by `chat-input.test.tsx` in @multica/views; this
* spec is the end-to-end contract proof: the backend really does persist
* chat_session_id at upload and back-fill chat_message_id at send.
*/
import "./env";
import { test, expect } from "@playwright/test";
import pg from "pg";
import { createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
const API_BASE =
process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
const DATABASE_URL =
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
interface UploadRow {
id: string;
url: string;
chat_session_id: string | null;
chat_message_id: string | null;
}
async function authedFetch(api: TestApiClient, path: string, init?: RequestInit) {
const token = api.getToken();
if (!token) throw new Error("test api client not logged in");
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
...((init?.headers as Record<string, string>) ?? {}),
};
return fetch(`${API_BASE}${path}`, { ...init, headers });
}
test.describe("Chat attachments", () => {
let api: TestApiClient;
let pgClient: pg.Client | null = null;
let createdSessionId: string | null = null;
let createdAgentId: string | null = null;
let createdRuntimeId: string | null = null;
test.beforeEach(async () => {
api = await createTestApi();
pgClient = new pg.Client(DATABASE_URL);
await pgClient.connect();
});
test.afterEach(async () => {
try {
if (pgClient) {
if (createdSessionId) {
await pgClient.query(`DELETE FROM chat_session WHERE id = $1`, [createdSessionId]);
}
if (createdAgentId) {
await pgClient.query(`DELETE FROM agent WHERE id = $1`, [createdAgentId]);
}
if (createdRuntimeId) {
await pgClient.query(`DELETE FROM agent_runtime WHERE id = $1`, [createdRuntimeId]);
}
}
} finally {
if (pgClient) await pgClient.end();
pgClient = null;
createdSessionId = null;
createdAgentId = null;
createdRuntimeId = null;
await api.cleanup();
}
});
test("upload-file binds attachment to the chat_session; send back-fills chat_message_id", async () => {
expect(pgClient).not.toBeNull();
const pgc = pgClient!;
// Resolve the workspace + caller so we can seed an agent/runtime/session
// directly via SQL. Going through the HTTP API would require modelling
// local-daemon ownership which isn't needed for this contract test.
const workspaces = await api.getWorkspaces();
const ws = workspaces[0]!;
api.setWorkspaceSlug(ws.slug);
api.setWorkspaceId(ws.id);
const userRow = await pgc.query(
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
["e2e@multica.ai"],
);
if (userRow.rows.length === 0) throw new Error("e2e user missing");
const userId = userRow.rows[0].id as string;
// Seed runtime + agent + chat_session.
const runtimeIns = await pgc.query(
`INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
RETURNING id`,
[ws.id, `e2e chat runtime ${Date.now()}`, "e2e_chat_runtime", "E2E chat runtime"],
);
createdRuntimeId = runtimeIns.rows[0].id as string;
const agentIns = await pgc.query(
`INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
RETURNING id`,
[ws.id, `E2E Chat Agent ${Date.now()}`, createdRuntimeId, userId],
);
createdAgentId = agentIns.rows[0].id as string;
const sessionIns = await pgc.query(
`INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
VALUES ($1, $2, $3, 'E2E Chat Attachment Session', 'active')
RETURNING id`,
[ws.id, createdAgentId, userId],
);
createdSessionId = sessionIns.rows[0].id as string;
// 1. Upload a small PNG against the chat session.
const pngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
]);
const form = new FormData();
form.append("file", new Blob([new Uint8Array(pngBytes)], { type: "image/png" }), "e2e.png");
form.append("chat_session_id", createdSessionId);
const uploadRes = await authedFetch(api, "/api/upload-file", {
method: "POST",
body: form,
headers: { "X-Workspace-Slug": ws.slug },
});
expect(uploadRes.status).toBe(200);
const uploaded = (await uploadRes.json()) as UploadRow;
expect(uploaded.chat_session_id).toBe(createdSessionId);
expect(uploaded.chat_message_id).toBeNull();
expect(uploaded.url).toBeTruthy();
// 2. Send a chat message that references the attachment.
const sendRes = await authedFetch(api, `/api/chat/sessions/${createdSessionId}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Workspace-Slug": ws.slug,
},
body: JSON.stringify({
content: `look at this ![](${uploaded.url})`,
attachment_ids: [uploaded.id],
}),
});
expect(sendRes.status).toBe(201);
const sendBody = (await sendRes.json()) as { message_id: string; task_id: string };
expect(sendBody.message_id).toBeTruthy();
// 3. DB check: the attachment row's chat_message_id matches the new message.
const after = await pgc.query<{ chat_message_id: string | null }>(
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
[uploaded.id],
);
expect(after.rows[0]?.chat_message_id).toBe(sendBody.message_id);
// 4. Clean up the attachment we created (chat_session cascade handles the
// rest in afterEach via chat_session row deletion).
await pgc.query(`DELETE FROM attachment WHERE id = $1`, [uploaded.id]);
});
});

View File

@@ -49,6 +49,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
device_info: "",
metadata: {},
owner_id: null,
visibility: "private",
timezone: "UTC",
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",

View File

@@ -82,3 +82,30 @@ export function agentTasksOptions(wsId: string, agentId: string) {
refetchOnWindowFocus: true,
});
}
// Agent templates are workspace-independent: a static catalog served from
// the server's embedded JSON. Cache effectively forever — the only way the
// list / detail change is a server deploy, and a hard reload picks that up.
export const agentTemplateKeys = {
all: () => ["agent-templates"] as const,
list: () => [...agentTemplateKeys.all(), "list"] as const,
detail: (slug: string) => [...agentTemplateKeys.all(), "detail", slug] as const,
};
export function agentTemplateListOptions() {
return queryOptions({
queryKey: agentTemplateKeys.list(),
queryFn: () => api.listAgentTemplates(),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}
export function agentTemplateDetailOptions(slug: string) {
return queryOptions({
queryKey: agentTemplateKeys.detail(slug),
queryFn: () => api.getAgentTemplate(slug),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}

View File

@@ -199,4 +199,122 @@ describe("ApiClient", () => {
expect(att.download_url).toBe("");
});
});
describe("getAttachmentTextContent", () => {
it("returns body text and the original content type from the X-* header", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("# heading\n\nbody\n", {
status: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Original-Content-Type": "text/markdown",
},
}),
),
);
const client = new ApiClient("https://api.example.test");
const { text, originalContentType } =
await client.getAttachmentTextContent("att-1");
expect(text).toBe("# heading\n\nbody\n");
expect(originalContentType).toBe("text/markdown");
});
it("throws PreviewTooLargeError on 413", async () => {
const { PreviewTooLargeError } = await import("./client");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("", { status: 413, statusText: "Payload Too Large" }),
),
);
const client = new ApiClient("https://api.example.test");
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
PreviewTooLargeError,
);
});
it("throws PreviewUnsupportedError on 415", async () => {
const { PreviewUnsupportedError } = await import("./client");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
),
);
const client = new ApiClient("https://api.example.test");
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
PreviewUnsupportedError,
);
});
});
describe("chat attachment wiring", () => {
it("uploadFile includes chat_session_id in the FormData body", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
const file = new File(["hi"], "hi.png", { type: "image/png" });
await client.uploadFile(file, { chatSessionId: "session-123" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.example.test/api/upload-file");
expect(init?.method).toBe("POST");
const body = init?.body as FormData;
expect(body).toBeInstanceOf(FormData);
expect(body.get("chat_session_id")).toBe("session-123");
expect(body.get("issue_id")).toBeNull();
expect(body.get("comment_id")).toBeNull();
});
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
content: "hello",
attachment_ids: ["att-1", "att-2"],
});
});
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.sendChatMessage("session-1", "hello");
await client.sendChatMessage("session-1", "again", []);
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
});
});
});

View File

@@ -11,6 +11,10 @@ import type {
ListIssuesParams,
Agent,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
UpdateAgentRequest,
AgentTask,
AgentActivityBucket,
@@ -38,6 +42,9 @@ import type {
RuntimeHourlyActivity,
RuntimeUsageByAgent,
RuntimeUsageByHour,
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
@@ -81,6 +88,11 @@ import type {
ListAutopilotRunsResponse,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
Squad,
SquadMember,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -88,10 +100,19 @@ import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
import { parseWithFallback } from "./schema";
import {
AgentTemplateSchema,
AgentTemplateSummaryListSchema,
AttachmentResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CreateAgentFromTemplateResponseSchema,
DashboardAgentRunTimeListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
EMPTY_AGENT_TEMPLATE_DETAIL,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_ATTACHMENT,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_TIMELINE_ENTRIES,
ListIssuesResponseSchema,
@@ -187,6 +208,27 @@ export class ApiError extends Error {
}
}
// Thrown by getAttachmentTextContent when the server refuses to inline a
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
// download" affordance with the Download CTA still available.
export class PreviewTooLargeError extends Error {
constructor() {
super("attachment too large for inline preview");
this.name = "PreviewTooLargeError";
}
}
// Thrown by getAttachmentTextContent when the server's text whitelist
// rejects the content type. Normally the client's isPreviewable() guard
// catches this earlier, but the two whitelists can drift — surfacing the
// 415 as a typed error makes the drift visible.
export class PreviewUnsupportedError extends Error {
constructor() {
super("attachment type not supported for inline preview");
this.name = "PreviewUnsupportedError";
}
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
@@ -261,15 +303,23 @@ export class ApiClient {
}
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
// Sends the request with the standard headers (auth, CSRF, request id,
// client identity) and runs the shared error path (401 → handleUnauthorized,
// structured ApiError, status-aware log level). Returns the raw Response so
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
// path, plain text for the attachment-preview proxy, etc.
private async fetchRaw(
path: string,
init?: RequestInit & { extraHeaders?: Record<string, string> },
): Promise<Response> {
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Request-ID": rid,
...this.authHeaders(),
...(init?.extraHeaders ?? {}),
...((init?.headers as Record<string, string>) ?? {}),
};
@@ -290,12 +340,18 @@ export class ApiClient {
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
return res;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await this.fetchRaw(path, {
...init,
extraHeaders: { "Content-Type": "application/json" },
});
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
@@ -625,6 +681,51 @@ export class ApiClient {
});
}
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
const raw = await this.fetch<unknown>("/api/agent-templates");
return parseWithFallback(
raw,
AgentTemplateSummaryListSchema,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
{ endpoint: "GET /api/agent-templates" },
);
}
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
const raw = await this.fetch<unknown>(
`/api/agent-templates/${encodeURIComponent(slug)}`,
);
// Round-trip the requested slug into the fallback so a malformed
// detail response still produces a navigable record matching the URL
// the user clicked.
return parseWithFallback(
raw,
AgentTemplateSchema,
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
{ endpoint: "GET /api/agent-templates/:slug" },
);
}
/** Creates an agent from a curated template. The server fetches every
* referenced skill URL in parallel, materializes them into the workspace
* (find-or-create by name), and writes the agent + skill bindings in a
* single transaction. On any upstream fetch failure, the entire write is
* rolled back and the API returns 422 with `failed_urls`. */
async createAgentFromTemplate(
data: CreateAgentFromTemplateRequest,
): Promise<CreateAgentFromTemplateResponse> {
const raw = await this.fetch<unknown>("/api/agents/from-template", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
CreateAgentFromTemplateResponseSchema,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
{ endpoint: "POST /api/agents/from-template" },
);
}
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
return this.fetch(`/api/agents/${id}`, {
method: "PUT",
@@ -661,7 +762,7 @@ export class ApiClient {
async updateRuntime(
runtimeId: string,
patch: { timezone?: string },
patch: { timezone?: string; visibility?: "private" | "public" },
): Promise<AgentRuntime> {
return this.fetch(`/api/runtimes/${runtimeId}`, {
method: "PATCH",
@@ -697,6 +798,58 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
}
// ---------------------------------------------------------------------------
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
// Each accepts an optional `project_id` to narrow the scope to one project.
// Cost is computed client-side from the model pricing table (same contract
// as the per-runtime endpoints above).
// ---------------------------------------------------------------------------
async getDashboardUsageDaily(
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
return parseWithFallback<DashboardUsageDaily[]>(
raw,
DashboardUsageDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/daily" },
);
}
async getDashboardUsageByAgent(
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageByAgent[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
return parseWithFallback<DashboardUsageByAgent[]>(
raw,
DashboardUsageByAgentListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/by-agent" },
);
}
async getDashboardAgentRunTime(
params: { days?: number; project_id?: string | null },
): Promise<DashboardAgentRunTime[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
return parseWithFallback<DashboardAgentRunTime[]>(
raw,
DashboardAgentRunTimeListSchema,
[],
{ endpoint: "GET /api/dashboard/agent-runtime" },
);
}
async initiateUpdate(
runtimeId: string,
targetVersion: string,
@@ -1021,11 +1174,15 @@ export class ApiClient {
}
// File Upload & Attachments
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
async uploadFile(
file: File,
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
): Promise<Attachment> {
const formData = new FormData();
formData.append("file", file);
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
const rid = createRequestId();
const start = Date.now();
@@ -1046,7 +1203,10 @@ export class ApiClient {
}
this.logger.info(`${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
return res.json() as Promise<Attachment>;
const raw = (await res.json()) as unknown;
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "POST /api/upload-file",
});
}
// Chat Sessions
@@ -1070,14 +1230,29 @@ export class ApiClient {
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
}
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
return this.fetch(`/api/chat/sessions/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
}
async sendChatMessage(sessionId: string, content: string): Promise<SendChatMessageResponse> {
async sendChatMessage(
sessionId: string,
content: string,
attachmentIds?: string[],
): Promise<SendChatMessageResponse> {
const body: { content: string; attachment_ids?: string[] } = { content };
if (attachmentIds && attachmentIds.length > 0) {
body.attachment_ids = attachmentIds;
}
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
method: "POST",
body: JSON.stringify({ content }),
body: JSON.stringify(body),
});
}
@@ -1116,6 +1291,38 @@ export class ApiClient {
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
}
// Fetches the raw bytes of a text-previewable attachment.
//
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
// bypasses Content-Disposition: attachment for the `text/*` family, both
// of which would otherwise prevent the renderer from getting the body.
// The server always replies with `text/plain; charset=utf-8` for safety;
// the original MIME ships back in the `X-Original-Content-Type` header so
// the preview dispatcher can choose between markdown / html / plain code.
//
// Routes through `fetchRaw` so it inherits the standard auth headers,
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
// the modal can render specific fallbacks instead of generic failure.
async getAttachmentTextContent(
id: string,
): Promise<{ text: string; originalContentType: string }> {
let res: Response;
try {
res = await this.fetchRaw(`/api/attachments/${id}/content`);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 413) throw new PreviewTooLargeError();
if (err.status === 415) throw new PreviewUnsupportedError();
}
throw err;
}
return {
text: await res.text(),
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
};
}
// Projects
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
const search = new URLSearchParams();
@@ -1238,6 +1445,43 @@ export class ApiClient {
});
}
// Squads
async listSquads(): Promise<Squad[]> {
return this.fetch(`/api/squads`);
}
async getSquad(id: string): Promise<Squad> {
return this.fetch(`/api/squads/${id}`);
}
async createSquad(data: { name: string; description?: string; leader_id: string }): Promise<Squad> {
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
}
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
}
async deleteSquad(id: string): Promise<void> {
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
}
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
return this.fetch(`/api/squads/${squadId}/members`);
}
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
}
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
}
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
@@ -1295,4 +1539,23 @@ export class ApiClient {
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
}
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
}
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
method: "DELETE",
});
}
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
return this.fetch(`/api/issues/${issueId}/pull-requests`);
}
}

View File

@@ -1,4 +1,9 @@
export { ApiClient, ApiError } from "./client";
export {
ApiClient,
ApiError,
PreviewTooLargeError,
PreviewUnsupportedError,
} from "./client";
export type {
ApiClientOptions,
ImportStarterContentPayload,

View File

@@ -117,6 +117,108 @@ describe("ApiClient schema fallback", () => {
expect(res).toEqual({ issues: [] });
});
});
// Agent template catalog is hit by the desktop create-agent picker.
// Installed desktop builds outlive any given server, so the shape MUST
// survive future field renames / wrapping without crashing. Each test
// here mirrors a concrete future drift we want to absorb.
describe("listAgentTemplates", () => {
it("falls back to [] when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toEqual([]);
});
it("defaults skills to [] when the field is missing from a template", async () => {
// Future server: drops `skills` because the picker no longer reads
// them. Picker code calls `template.skills.length` — must not throw.
stubFetchJson([{ slug: "x", name: "X" }]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.skills).toEqual([]);
});
it("accepts the bare-array shape (current contract)", async () => {
stubFetchJson([
{ slug: "a", name: "A", description: "", skills: [] },
{ slug: "b", name: "B", description: "", skills: [] },
]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
});
it("accepts a future {templates: [...]} envelope without breaking", async () => {
// Server migrates to a paginated envelope. We unwrap so the picker
// keeps working on the older bare-array consumer.
stubFetchJson({
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.slug).toBe("a");
});
});
describe("getAgentTemplate", () => {
it("falls back to a minimal record carrying the requested slug", async () => {
// Slug is part of the URL the user clicked — the fallback round-
// trips it so the page header still makes sense after a parse miss.
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.slug).toBe("code-reviewer");
expect(detail.skills).toEqual([]);
expect(detail.instructions).toBe("");
});
it("defaults instructions to '' when the field is missing", async () => {
stubFetchJson({
slug: "code-reviewer",
name: "Code Reviewer",
description: "",
skills: [],
});
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.instructions).toBe("");
});
});
describe("createAgentFromTemplate", () => {
it("falls back to an empty agent when the response is malformed", async () => {
// The agent was created server-side even though the client can't
// parse the response — UI code reads `agent.id === ""` and skips
// the navigation step rather than landing on `/agents/`.
stubFetchJson({ unexpected: "shape" });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
stubFetchJson({ agent: { id: "agent-1" } });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("agent-1");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
});
});
// Direct tests for the helper, decoupled from any specific endpoint —

View File

@@ -1,5 +1,13 @@
import { z } from "zod";
import type { Attachment, ListIssuesResponse, TimelineEntry } from "../types";
import type {
Agent,
AgentTemplate,
AgentTemplateSummary,
Attachment,
CreateAgentFromTemplateResponse,
ListIssuesResponse,
TimelineEntry,
} from "../types";
// ---------------------------------------------------------------------------
// Schemas for the highest-risk API endpoints — those whose responses drive
@@ -55,6 +63,8 @@ export const AttachmentResponseSchema = z.object({
url: z.string(),
download_url: z.string(),
filename: z.string(),
chat_session_id: z.string().nullable().optional(),
chat_message_id: z.string().nullable().optional(),
}).loose();
export const EMPTY_ATTACHMENT: Attachment = {
@@ -62,6 +72,8 @@ export const EMPTY_ATTACHMENT: Attachment = {
workspace_id: "",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "",
uploader_id: "",
filename: "",
@@ -165,3 +177,132 @@ export const SubscribersListSchema = z.array(SubscriberSchema);
export const ChildIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
}).loose();
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
// The dashboard hits three independent rollup endpoints. Each returns a flat
// array, and every field is consumed by chart / KPI math — a missing number
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
// String fields stay lenient (no enum narrowing) to survive future model /
// agent ID drift.
// ---------------------------------------------------------------------------
const DashboardUsageDailySchema = z.object({
date: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
const DashboardUsageByAgentSchema = z.object({
agent_id: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
const DashboardAgentRunTimeSchema = z.object({
agent_id: z.string(),
total_seconds: z.number().default(0),
task_count: z.number().default(0),
failed_count: z.number().default(0),
}).loose();
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
// ---------------------------------------------------------------------------
// Agent template catalog — `/api/agent-templates*` and the
// create-from-template response. The desktop app's create-agent picker
// reaches these endpoints, and a future server change to the template shape
// would white-screen older installed builds (#2192 pattern) without these
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
// through unchanged.
// ---------------------------------------------------------------------------
const AgentTemplateSkillRefSchema = z.object({
source_url: z.string(),
cached_name: z.string().default(""),
cached_description: z.string().default(""),
}).loose();
const AgentTemplateSummarySchemaBase = z.object({
slug: z.string(),
name: z.string(),
description: z.string().default(""),
category: z.string().optional(),
icon: z.string().optional(),
accent: z.string().optional(),
// skills MUST default to [] — picker code reads `template.skills.length`
// and `.map(...)`, both of which crash on `undefined`. The most common
// future drift (field renamed / wrapped) lands here.
skills: z.array(AgentTemplateSkillRefSchema).default([]),
}).loose();
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
// List endpoint historically returns a bare array. Server could legitimately
// migrate to `{templates: [...]}` later — we accept either shape so an old
// desktop survives the upgrade.
export const AgentTemplateSummaryListSchema = z.union([
z.array(AgentTemplateSummarySchemaBase),
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
.loose()
.transform((v) => v.templates),
]);
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
// Detail-only field. Default "" so a malformed detail still renders the
// header + skill list; the user just sees an empty Instructions block.
instructions: z.string().default(""),
}).loose();
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
// from the URL, so we round-trip the requested one back into the fallback
// at the call site (see `getAgentTemplate` in client.ts).
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
slug: "",
name: "",
description: "",
skills: [],
instructions: "",
};
// `agent` is a full Agent record — schematising every field would duplicate
// a 50-field interface and bit-rot fast. We keep it loose and require only
// `id`, the one field the create-from-template flow consumes (used to
// navigate to the new agent's detail page). Downstream code already
// optional-chains the rest.
const MinimalAgentSchema = z.object({
id: z.string(),
}).loose();
export const CreateAgentFromTemplateResponseSchema = z.object({
agent: MinimalAgentSchema,
imported_skill_ids: z.array(z.string()).default([]),
reused_skill_ids: z.array(z.string()).default([]),
}).loose();
// Fallback when the success response fails to parse. The agent server-side
// has likely been created already, so we can't pretend nothing happened —
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
// `agent.id === ""` and skipping navigation while keeping the list
// invalidation, so the user finds their new agent in the list.
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
agent: { id: "" } as Agent,
imported_skill_ids: [],
reused_skill_ids: [],
};

View File

@@ -64,6 +64,45 @@ export function useMarkChatSessionRead() {
});
}
/**
* Renames a chat session. Optimistically swaps the title in the cached
* list so the dropdown reflects the new label immediately; rolls back on
* error. The matching `chat:session_updated` WS event keeps other
* tabs/devices in sync — see use-realtime-sync.ts.
*/
export function useUpdateChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { sessionId: string; title: string }) => {
logger.info("updateChatSession.start", {
sessionId: data.sessionId,
titleLength: data.title.length,
});
return api.updateChatSession(data.sessionId, { title: data.title });
},
onMutate: async ({ sessionId, title }) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const patch = (old?: ChatSession[]) =>
old?.map((s) => (s.id === sessionId ? { ...s, title } : s));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
return { prevSessions };
},
onError: (err, vars, ctx) => {
logger.error("updateChatSession.error.rollback", { sessionId: vars.sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Hard-deletes a chat session. Optimistically removes the row from the
* sessions list so the dropdown updates instantly; rolls back on error.

View File

@@ -0,0 +1 @@
export * from "./queries";

View File

@@ -0,0 +1,72 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
// Workspace dashboard query options. All three endpoints share the same
// (wsId, days, projectId) key shape so workspace switching, time-range
// changes, and the project filter each invalidate the cache cleanly.
//
// The cache key includes `wsId` explicitly: TanStack Query already isolates
// per workspace via the key, but threading wsId into the queryFn lets
// callers fail fast (return [] on empty wsId) instead of issuing a request
// the server would reject.
//
// `projectId` is normalised to `null` (not undefined / "all") so the
// queryKey shape is stable across renders even when the dropdown sits on
// "all projects".
export const dashboardKeys = {
all: (wsId: string) => ["dashboard", wsId] as const,
daily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
byAgent: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
};
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
// driven on the server (5-min rollup cadence) and the dashboard isn't a
// real-time view, so background refetches every minute are plenty.
const STALE_TIME = 60 * 1000;
export function dashboardUsageDailyOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.daily(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}
export function dashboardUsageByAgentOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}
export function dashboardAgentRunTimeOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
queryFn: () =>
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}

View File

@@ -0,0 +1 @@
export * from "./queries";

View File

@@ -0,0 +1,22 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const githubKeys = {
all: (wsId: string) => ["github", wsId] as const,
installations: (wsId: string) => [...githubKeys.all(wsId), "installations"] as const,
pullRequests: (issueId: string) => ["github", "pull-requests", issueId] as const,
};
export const githubInstallationsOptions = (wsId: string) =>
queryOptions({
queryKey: githubKeys.installations(wsId),
queryFn: () => api.listGitHubInstallations(wsId),
enabled: !!wsId,
});
export const issuePullRequestsOptions = (issueId: string) =>
queryOptions({
queryKey: githubKeys.pullRequests(issueId),
queryFn: () => api.listIssuePullRequests(issueId),
enabled: !!issueId,
});

View File

@@ -14,6 +14,7 @@ export interface UploadResult {
export interface UploadContext {
issueId?: string;
commentId?: string;
chatSessionId?: string;
}
export function useFileUpload(
@@ -33,6 +34,7 @@ export function useFileUpload(
const att: Attachment = await api.uploadFile(file, {
issueId: ctx?.issueId,
commentId: ctx?.commentId,
chatSessionId: ctx?.chatSessionId,
});
return { id: att.id, filename: att.filename, link: att.url };
} finally {

View File

@@ -0,0 +1,80 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
/**
* Per-comment draft persistence — survives:
* - virtualization unmount (the reason this exists: when a TipTap editor
* scrolls out of the Virtuoso viewport, its in-memory state is lost)
* - tab close / accidental Cmd-W
* - reload
*
* Keys are issue-scoped because createWorkspaceAwareStorage only partitions
* by workspace, not by issue. Without issueId in the key, two issues with
* thread replies open in adjacent desktop tabs would collide.
*/
export type CommentDraftKey =
| `new:${string}` // top-level CommentInput, key = `new:${issueId}`
| `reply:${string}:${string}` // ReplyInput inside a thread, key = `reply:${issueId}:${rootCommentId}`
| `edit:${string}:${string}`; // inline edit on existing comment, key = `edit:${issueId}:${commentId}`
interface CommentDraft {
content: string;
updatedAt: number;
}
interface CommentDraftStore {
drafts: Record<string, CommentDraft>;
getDraft: (key: CommentDraftKey) => string | undefined;
setDraft: (key: CommentDraftKey, content: string) => void;
clearDraft: (key: CommentDraftKey) => void;
}
// Drafts older than 30 days are dropped on store init. Without TTL the store
// would accumulate every edit attempt across every issue indefinitely and
// slowly leak localStorage quota.
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
function pruneStaleDrafts(drafts: Record<string, CommentDraft>): Record<string, CommentDraft> {
const cutoff = Date.now() - TTL_MS;
const out: Record<string, CommentDraft> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v.updatedAt >= cutoff && v.content.trim().length > 0) {
out[k] = v;
}
}
return out;
}
export const useCommentDraftStore = create<CommentDraftStore>()(
persist(
(set, get) => ({
drafts: {},
getDraft: (key) => get().drafts[key]?.content,
setDraft: (key, content) =>
set((s) => ({
drafts: { ...s.drafts, [key]: { content, updatedAt: Date.now() } },
})),
clearDraft: (key) =>
set((s) => {
if (!(key in s.drafts)) return s;
const next = { ...s.drafts };
delete next[key];
return { drafts: next };
}),
}),
{
name: "multica_comment_drafts",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
onRehydrateStorage: () => (state) => {
if (state) {
state.drafts = pruneStaleDrafts(state.drafts);
}
},
},
),
);
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());

View File

@@ -13,6 +13,7 @@ export {
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export { useCommentCollapseStore } from "./comment-collapse-store";
export { useCommentDraftStore, type CommentDraftKey } from "./comment-draft-store";
export {
myIssuesViewStore,
type MyIssuesViewState,

View File

@@ -7,6 +7,7 @@ type ModalType =
| "create-issue"
| "quick-create-issue"
| "create-project"
| "create-squad"
| "feedback"
| "issue-set-parent"
| "issue-add-child"

View File

@@ -47,6 +47,8 @@
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
"./dashboard": "./dashboard/index.ts",
"./dashboard/queries": "./dashboard/queries.ts",
"./agents": "./agents/index.ts",
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",
@@ -66,6 +68,8 @@
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
"./github": "./github/index.ts",
"./github/queries": "./github/queries.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",

View File

@@ -17,14 +17,17 @@ describe("paths.workspace() shape", () => {
expect(new Set(parameterlessRoutes)).toEqual(
new Set([
"root",
"usage",
"issues",
"projects",
"autopilots",
"agents",
"squads",
"inbox",
"myIssues",
"runtimes",
"skills",
"squads",
"settings",
]),
);
@@ -35,14 +38,17 @@ describe("paths.workspace() shape", () => {
// Check that none of the parameterless paths embed a leaked literal
// and that their second URL segment matches the method name's kebab-case.
const expectedSegments: Array<[string, string]> = [
["usage", "usage"],
["issues", "issues"],
["projects", "projects"],
["autopilots", "autopilots"],
["agents", "agents"],
["squads", "squads"],
["inbox", "inbox"],
["myIssues", "my-issues"],
["runtimes", "runtimes"],
["skills", "skills"],
["squads", "squads"],
["settings", "settings"],
];
const wsAsAny = ws as unknown as Record<string, () => string>;

View File

@@ -4,7 +4,8 @@ import { paths, isGlobalPath } from "./paths";
describe("paths.workspace(slug)", () => {
const ws = paths.workspace("acme");
it("builds dashboard paths with slug prefix", () => {
it("builds workspace paths with slug prefix", () => {
expect(ws.usage()).toBe("/acme/usage");
expect(ws.issues()).toBe("/acme/issues");
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
expect(ws.projects()).toBe("/acme/projects");
@@ -17,6 +18,8 @@ describe("paths.workspace(slug)", () => {
expect(ws.runtimes()).toBe("/acme/runtimes");
expect(ws.skills()).toBe("/acme/skills");
expect(ws.skillDetail("skl_123")).toBe("/acme/skills/skl_123");
expect(ws.squads()).toBe("/acme/squads");
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
expect(ws.settings()).toBe("/acme/settings");
});

View File

@@ -18,6 +18,7 @@ function workspaceScoped(slug: string) {
const ws = `/${encode(slug)}`;
return {
root: () => `${ws}/issues`,
usage: () => `${ws}/usage`,
issues: () => `${ws}/issues`,
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
projects: () => `${ws}/projects`,
@@ -26,6 +27,8 @@ function workspaceScoped(slug: string) {
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
agents: () => `${ws}/agents`,
agentDetail: (id: string) => `${ws}/agents/${encode(id)}`,
squads: () => `${ws}/squads`,
squadDetail: (id: string) => `${ws}/squads/${encode(id)}`,
inbox: () => `${ws}/inbox`,
myIssues: () => `${ws}/my-issues`,
runtimes: () => `${ws}/runtimes`,

View File

@@ -70,7 +70,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"search",
"members",
// Dashboard / workspace route segments
// Workspace route segments
// Reserving each segment name prevents `/{slug}/{view}` from being visually
// ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two
// things). `workspaces` covers the global `/workspaces/new` workspace-creation
@@ -79,8 +79,10 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"projects",
"autopilots",
"agents",
"squads",
"inbox",
"my-issues",
"usage",
"runtimes",
"skills",
"settings",

View File

@@ -93,6 +93,7 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
device_info: "",
metadata: {},
owner_id: ownerId,
visibility: "private",
timezone: "UTC",
last_seen_at: null,
created_at: "2026-04-01T00:00:00Z",

View File

@@ -0,0 +1,117 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { chatKeys } from "../chat/queries";
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
import { applyChatDoneToCache } from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
const messagesKey = chatKeys.messages(sessionId);
const pendingKey = chatKeys.pendingTask(sessionId);
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function userMessage(): ChatMessage {
return {
id: "msg-user",
chat_session_id: sessionId,
role: "user",
content: "hello",
task_id: null,
created_at: "2026-05-13T05:00:00Z",
};
}
function donePayload(overrides: Partial<ChatDonePayload> = {}): ChatDonePayload {
return {
chat_session_id: sessionId,
task_id: taskId,
message_id: "msg-assistant",
content: "done",
elapsed_ms: 1234,
created_at: "2026-05-13T05:00:02Z",
...overrides,
};
}
describe("applyChatDoneToCache", () => {
it("writes the assistant message before clearing pending task", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
const setQueryData = vi.spyOn(qc, "setQueryData");
applyChatDoneToCache(qc, donePayload());
expect(setQueryData.mock.calls[0]?.[0]).toEqual(messagesKey);
expect(setQueryData.mock.calls[1]?.[0]).toEqual(pendingKey);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
{
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
},
]);
});
it("does not duplicate a replayed chat done event", () => {
const qc = createQueryClient();
const assistant: ChatMessage = {
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
};
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage(), assistant]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(qc, donePayload());
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
assistant,
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
it("falls back to invalidation-only when older servers omit message fields", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(
qc,
donePayload({ message_id: undefined, content: undefined }),
);
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import type { WSClient } from "../api/ws-client";
import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
@@ -20,6 +20,7 @@ import {
agentRunCountsKeys,
agentTasksKeys,
} from "../agents/queries";
import { githubKeys } from "../github/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -61,6 +62,7 @@ import type {
TaskFailedPayload,
TaskCancelledPayload,
ChatDonePayload,
ChatMessage,
ChatPendingTask,
InvitationCreatedPayload,
} from "../types";
@@ -69,6 +71,42 @@ const chatWsLogger = createLogger("chat.ws");
const logger = createLogger("realtime-sync");
export function applyChatDoneToCache(
qc: QueryClient,
payload: ChatDonePayload,
) {
const sessionId = payload.chat_session_id;
const taskId = payload.task_id;
const messageId = payload.message_id;
const content = payload.content;
if (messageId && content !== undefined) {
qc.setQueryData<ChatMessage[] | undefined>(
chatKeys.messages(sessionId),
(old) => {
if (!old) return old; // first fetch will pick it up
// Idempotent against reconnect replay.
if (old.some((m) => m.id === messageId)) return old;
const assistant: ChatMessage = {
id: messageId,
chat_session_id: sessionId,
role: "assistant",
content,
task_id: taskId,
created_at: payload.created_at ?? new Date().toISOString(),
elapsed_ms: payload.elapsed_ms ?? null,
};
return [...old, assistant];
},
);
}
// Replacement is in the messages list now; safe to drop pending.
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
// Authoritative refetch reconciles redaction / migrations / clients
// that took the fallback branch above.
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
export interface RealtimeSyncStores {
authStore: UseBoundStore<StoreApi<AuthState>>;
}
@@ -133,6 +171,14 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
squad: () => {
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// squad:deleted triggers assignee transfer — refresh issues too.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
}
},
label: () => {
// label:created/updated/deleted — also refresh issues, since each
// issue carries a denormalized snapshot of its labels (rename/recolor
@@ -157,6 +203,15 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
},
github_installation: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: githubKeys.installations(wsId) });
},
pull_request: () => {
// PR list is keyed by issue id, not workspace, so we invalidate all
// PR queries — the open issue detail page will refetch its own list.
qc.invalidateQueries({ queryKey: ["github", "pull-requests"] });
},
// Powers the agent presence cache: any task lifecycle change
// (dispatch / completed / failed / cancelled) refreshes the
// workspace-wide agent-task-snapshot query so per-agent presence
@@ -212,6 +267,7 @@ export function useRealtimeSync(
"daemon:heartbeat",
// Chat events are handled explicitly below; do not double-invalidate.
"chat:message", "chat:done", "chat:session_read", "chat:session_deleted",
"chat:session_updated",
// task:message stays out of the prefix path because it fires per
// streamed message during a long run — invalidating the snapshot on
// every message would flood the network. Specific chat handlers below
@@ -558,13 +614,21 @@ export function useRealtimeSync(
chatWsLogger.info("chat:done (global)", {
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
has_message: !!payload.message_id,
});
// Assistant message was just written and task flipped out of 'running'.
// Clear pending-task cache immediately so the live-timeline-vs-assistant
// race window collapses to zero — the subsequent refetch will confirm.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
// Inline-insert the assistant message into the messages cache BEFORE
// clearing pending-task. Both writes land in the same React render
// tick, so ChatMessageList sees `pendingAlreadyPersisted === true`
// and the live TimelineView unmounts only after AssistantMessage has
// mounted — no flicker window. This applies TkDodo's "combine
// setQueryData (active query) + invalidateQueries (others)" pattern
// (https://tkdodo.eu/blog/using-web-sockets-with-react-query).
//
// Falls back to invalidate-only when the server omits the message
// payload (older builds). Older clients hitting a newer server also
// work: they ignore the extra fields and rely on the invalidate
// below, which keeps the old behavior alive.
applyChatDoneToCache(qc, payload);
invalidatePendingAggregate();
// Assistant message just landed → has_unread may have flipped to true.
invalidateSessionLists();
@@ -635,9 +699,12 @@ export function useRealtimeSync(
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
// `chat:done` (broadcast immediately before this event in CompleteTask)
// already wrote the assistant message into the messages cache and
// cleared `chatKeys.pendingTask`. This event is now only responsible
// for refreshing the per-user cross-session aggregate that drives the
// FAB indicator — `chat:done` is per-session and doesn't carry that
// information.
invalidatePendingAggregate();
});
@@ -666,6 +733,33 @@ export function useRealtimeSync(
invalidateSessionLists();
});
// chat:session_updated fires after the creator renames a session in
// any tab/device. Patch the cached row inline so the dropdown reflects
// the new title without a full sessions-list refetch.
const unsubChatSessionUpdated = ws.on("chat:session_updated", (p) => {
const payload = p as {
chat_session_id: string;
title?: string;
updated_at?: string;
};
chatWsLogger.info("chat:session_updated (global)", payload);
const id = getCurrentWsId();
if (!id) return;
const patch = (
old?: { id: string; title: string; updated_at: string }[],
) =>
old?.map((s) =>
s.id === payload.chat_session_id
? {
...s,
title: payload.title ?? s.title,
updated_at: payload.updated_at ?? s.updated_at,
}
: s,
);
qc.setQueryData(chatKeys.sessions(id), patch);
});
// chat:session_deleted fires after a hard delete. The originating tab has
// already optimistically dropped the row via useDeleteChatSession; this
// handler keeps OTHER tabs/devices in sync and also clears the active
@@ -726,6 +820,7 @@ export function useRealtimeSync(
unsubTaskFailed();
unsubChatSessionRead();
unsubChatSessionDeleted();
unsubChatSessionUpdated();
timers.forEach(clearTimeout);
timers.clear();
};

View File

@@ -17,6 +17,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
device_info: "",
metadata: {},
owner_id: null,
visibility: "private",
timezone: "UTC",
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
created_at: "2026-04-01T00:00:00Z",

View File

@@ -12,11 +12,12 @@ export function useDeleteRuntime(wsId: string) {
});
}
// useUpdateRuntime patches editable fields on a runtime (currently the
// reporting timezone). Invalidates the runtime list AND any keys downstream
// of the updated runtime — usage queries are bucketed by tz on the server,
// so a tz change must blow away cached usage rows or the chart would lie
// for one polling cycle.
// useUpdateRuntime patches editable fields on a runtime (timezone, visibility).
// Invalidates the runtime list AND any keys downstream of the updated runtime
// — usage queries are bucketed by tz on the server, so a tz change must blow
// away cached usage rows or the chart would lie for one polling cycle. A
// visibility change only needs the runtime list to refetch so the picker
// disabled-state recomputes.
export function useUpdateRuntime(wsId: string) {
const qc = useQueryClient();
return useMutation({
@@ -25,11 +26,11 @@ export function useUpdateRuntime(wsId: string) {
patch,
}: {
runtimeId: string;
patch: { timezone?: string };
patch: { timezone?: string; visibility?: "private" | "public" };
}) => api.updateRuntime(runtimeId, patch),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
if (vars) {
if (vars && vars.patch.timezone !== undefined) {
// Usage query keys are not workspace-scoped; invalidate only this
// runtime's daily/by-agent/by-hour usage rows under the new tz buckets.
qc.invalidateQueries({

View File

@@ -4,6 +4,14 @@ export type AgentRuntimeMode = "local" | "cloud";
export type AgentVisibility = "workspace" | "private";
// Runtime visibility is a separate axis from agent visibility — different
// vocabulary because it gates a different action. "private" (default) means
// only the runtime owner and workspace admins can bind agents to it;
// "public" opens binding to any workspace member. Older backends that
// haven't shipped MUL-2062 omit the field; the consumer must default to
// "private" so the strictest behavior is the fallback.
export type RuntimeVisibility = "private" | "public";
export interface RuntimeDevice {
id: string;
workspace_id: string;
@@ -16,6 +24,8 @@ export interface RuntimeDevice {
device_info: string;
metadata: Record<string, unknown>;
owner_id: string | null;
/** Defaults to "private" when the backend predates the visibility flag. */
visibility: RuntimeVisibility;
timezone: string;
last_seen_at: string | null;
created_at: string;
@@ -158,6 +168,76 @@ export interface CreateAgentRequest {
template?: string;
}
/** Agent template summary — fields needed by the picker grid. Does NOT
* include `instructions` to keep the list payload small; the detail
* endpoint or the create flow returns the full template body. */
export interface AgentTemplateSummary {
slug: string;
name: string;
description: string;
/** Optional grouping for the picker UI ("Engineering" / "Writing" / …). */
category?: string;
/** Optional lucide-react icon name (e.g. "Search"). Frontend falls back
* to a generic icon when empty. */
icon?: string;
/** Optional semantic color token for the icon badge — one of "info" /
* "success" / "warning" / "primary" / "secondary". Frontend has a
* static class map so Tailwind can JIT-scan all variants. */
accent?: string;
skills: AgentTemplateSkillRef[];
}
/** Full agent template — same as `AgentTemplateSummary` plus the
* instructions block. Returned by `GET /api/agent-templates/:slug`. */
export interface AgentTemplate extends AgentTemplateSummary {
instructions: string;
}
/** Skill reference inside an agent template. `source_url` is the upstream
* GitHub / skills.sh URL fetched on create; `cached_*` mirror the upstream
* frontmatter at template-author time and let the picker render without
* HTTP fetches. */
export interface AgentTemplateSkillRef {
source_url: string;
cached_name: string;
cached_description: string;
}
export interface CreateAgentFromTemplateRequest {
template_slug: string;
name: string;
runtime_id: string;
model?: string;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
/** Optional overrides applied to the template before creation. nil/omit
* uses the template's own value. */
description?: string;
instructions?: string;
avatar_url?: string;
/** Workspace skill IDs attached **in addition to** the template's
* skills. Server dedupes against template skills automatically. */
extra_skill_ids?: string[];
}
export interface CreateAgentFromTemplateResponse {
agent: Agent;
/** Skill IDs that were newly created in the workspace from upstream URLs. */
imported_skill_ids: string[];
/** Skill IDs that already existed in the workspace (same name) and were
* reused rather than re-imported. The UI can surface this as a toast so
* the user knows their pre-existing skill wasn't overwritten. */
reused_skill_ids: string[];
}
/** 422 body returned by `POST /api/agents/from-template` when one or more
* template skill URLs cannot be reached. The transaction is rolled back —
* no partial workspace state. */
export interface CreateAgentFromTemplateFailure {
error: string;
failed_urls: string[];
}
export interface UpdateAgentRequest {
name?: string;
description?: string;
@@ -279,6 +359,44 @@ export interface RuntimeUsageByHour {
task_count: number;
}
// One (date, model) bucket of token usage for the workspace dashboard.
// Same shape as RuntimeUsage but workspace-scoped (no runtime_id, no
// provider field on the wire) and optionally narrowed to a single project
// on the server side. Cost stays client-side via the model pricing table.
export interface DashboardUsageDaily {
date: string;
model: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
task_count: number;
}
// Per-(agent, model) token totals for the workspace dashboard. Identical
// wire shape to RuntimeUsageByAgent — the client folds by agent_id and
// sums cost.
export interface DashboardUsageByAgent {
agent_id: string;
model: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
task_count: number;
}
// Per-agent total terminal-task run-time + counts. Powers the workspace
// dashboard's "time by agent" list. failed_count is a subset of
// task_count (failed tasks still contribute to total_seconds because
// they consumed runtime to fail).
export interface DashboardAgentRunTime {
agent_id: string;
total_seconds: number;
task_count: number;
failed_count: number;
}
export type RuntimeUpdateStatus =
| "pending"
| "running"

View File

@@ -3,6 +3,8 @@ export interface Attachment {
workspace_id: string;
issue_id: string | null;
comment_id: string | null;
chat_session_id: string | null;
chat_message_id: string | null;
uploader_type: string;
uploader_id: string;
filename: string;

View File

@@ -28,6 +28,14 @@ export interface ChatMessage {
content: string;
task_id: string | null;
created_at: string;
/**
* Attachments linked to this message via the attachment table's
* chat_message_id FK. Populated by ListChatMessages. UI renders these
* as file/image cards inside the bubble; the markdown URL inline in
* `content` may have an expiring signature, while attachment metadata
* here is stable and the source of truth for click-time download.
*/
attachments?: import("./attachment").Attachment[];
/**
* When set, this is an assistant message synthesized by the server's
* FailTask fallback (mirrors the issue path's failure system comment).

View File

@@ -54,9 +54,13 @@ export type WSEventType =
| "chat:done"
| "chat:session_read"
| "chat:session_deleted"
| "chat:session_updated"
| "project:created"
| "project:updated"
| "project:deleted"
| "squad:created"
| "squad:updated"
| "squad:deleted"
| "label:created"
| "label:updated"
| "label:deleted"
@@ -67,7 +71,12 @@ export type WSEventType =
| "invitation:created"
| "invitation:accepted"
| "invitation:declined"
| "invitation:revoked";
| "invitation:revoked"
| "github_installation:created"
| "github_installation:deleted"
| "pull_request:linked"
| "pull_request:updated"
| "pull_request:unlinked";
export interface WSMessage<T = unknown> {
type: WSEventType;
@@ -284,7 +293,16 @@ export interface ChatMessageEventPayload {
export interface ChatDonePayload {
chat_session_id: string;
task_id: string;
/**
* Server populates these from the freshly-persisted assistant ChatMessage
* row so the WS handler can write it into the messages cache inline. Older
* servers (pre-#2123) only sent chat_session_id + task_id; treat every field
* below as optional and fall back to a refetch when absent.
*/
message_id?: string;
content?: string;
elapsed_ms?: number;
created_at?: string;
}
export interface ChatSessionReadPayload {

View File

@@ -0,0 +1,41 @@
export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
export interface GitHubInstallation {
id: string;
workspace_id: string;
installation_id: number;
account_login: string;
account_type: "User" | "Organization";
account_avatar_url: string | null;
created_at: string;
}
export interface GitHubPullRequest {
id: string;
workspace_id: string;
repo_owner: string;
repo_name: string;
number: number;
title: string;
state: GitHubPullRequestState;
html_url: string;
branch: string | null;
author_login: string | null;
author_avatar_url: string | null;
merged_at: string | null;
closed_at: string | null;
pr_created_at: string;
pr_updated_at: string;
}
export interface ListGitHubInstallationsResponse {
installations: GitHubInstallation[];
/** Whether the deployment has GitHub App credentials configured. When false, the Connect button is hidden / disabled. */
configured: boolean;
}
export interface GitHubConnectResponse {
/** The GitHub App install URL the browser should open. Empty when `configured` is false. */
url?: string;
configured: boolean;
}

View File

@@ -25,7 +25,7 @@ export interface InboxItem {
workspace_id: string;
recipient_type: "member" | "agent";
recipient_id: string;
actor_type: "member" | "agent" | null;
actor_type: "member" | "agent" | "system" | null;
actor_id: string | null;
type: InboxItemType;
severity: InboxSeverity;

View File

@@ -11,6 +11,12 @@ export type {
AgentRuntime,
RuntimeDevice,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
AgentTemplateSkillRef,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
CreateAgentFromTemplateFailure,
UpdateAgentRequest,
Skill,
SkillSummary,
@@ -23,6 +29,9 @@ export type {
RuntimeHourlyActivity,
RuntimeUsageByAgent,
RuntimeUsageByHour,
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
RuntimeUpdate,
RuntimeUpdateStatus,
RuntimeModel,
@@ -67,6 +76,13 @@ export type {
ListProjectResourcesResponse,
} from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
GitHubInstallation,
GitHubPullRequest,
GitHubPullRequestState,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
} from "./github";
export type {
Autopilot,
AutopilotStatus,
@@ -84,3 +100,16 @@ export type {
GetAutopilotResponse,
ListAutopilotRunsResponse,
} from "./autopilot";
export type {
Squad,
SquadMember,
SquadMemberType,
SquadActivityLog,
SquadActivityOutcome,
CreateSquadRequest,
UpdateSquadRequest,
AddSquadMemberRequest,
RemoveSquadMemberRequest,
UpdateSquadMemberRoleRequest,
CreateSquadActivityLogRequest,
} from "./squad";

View File

@@ -11,7 +11,7 @@ export type IssueStatus =
export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none";
export type IssueAssigneeType = "member" | "agent";
export type IssueAssigneeType = "member" | "agent" | "squad";
export interface IssueReaction {
id: string;

View File

@@ -0,0 +1,77 @@
export type SquadMemberType = "agent" | "member";
export type SquadActivityOutcome = "action" | "no_action" | "failed";
export interface Squad {
id: string;
workspace_id: string;
name: string;
description: string;
instructions: string;
avatar_url: string | null;
leader_id: string;
creator_id: string;
created_at: string;
updated_at: string;
archived_at: string | null;
archived_by: string | null;
}
export interface SquadMember {
id: string;
squad_id: string;
member_type: SquadMemberType;
member_id: string;
role: string;
created_at: string;
}
export interface SquadActivityLog {
id: string;
squad_id: string;
issue_id: string;
trigger_comment_id: string | null;
leader_id: string;
outcome: SquadActivityOutcome;
details: unknown;
created_at: string;
}
export interface CreateSquadRequest {
name: string;
description?: string;
leader_id: string;
}
export interface UpdateSquadRequest {
name?: string;
description?: string;
instructions?: string;
leader_id?: string;
avatar_url?: string;
}
export interface AddSquadMemberRequest {
member_type: SquadMemberType;
member_id: string;
role?: string;
}
export interface RemoveSquadMemberRequest {
member_type: SquadMemberType;
member_id: string;
}
export interface UpdateSquadMemberRoleRequest {
member_type: SquadMemberType;
member_id: string;
role: string;
}
export interface CreateSquadActivityLogRequest {
squad_id: string;
issue_id: string;
trigger_comment_id?: string;
outcome: SquadActivityOutcome;
details?: unknown;
}

View File

@@ -2,12 +2,13 @@
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "../hooks";
import { memberListOptions, agentListOptions } from "./queries";
import { memberListOptions, agentListOptions, squadListOptions } from "./queries";
export function useActorName() {
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
const getMemberName = (userId: string) => {
const m = members.find((m) => m.user_id === userId);
@@ -19,9 +20,16 @@ export function useActorName() {
return a?.name ?? "Unknown Agent";
};
const getSquadName = (squadId: string) => {
const s = squads.find((s) => s.id === squadId);
return s?.name ?? "Unknown Squad";
};
const getActorName = (type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
if (type === "squad") return getSquadName(id);
if (type === "system") return "Multica";
return "System";
};
@@ -38,8 +46,9 @@ export function useActorName() {
const getActorAvatarUrl = (type: string, id: string): string | null => {
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
if (type === "squad") return squads.find((s) => s.id === id)?.avatar_url ?? null;
return null;
};
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
return { getMemberName, getAgentName, getSquadName, getActorName, getActorInitials, getActorAvatarUrl };
}

View File

@@ -1,6 +1,6 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { Agent, Workspace } from "../types";
import type { Agent, Squad, Workspace } from "../types";
export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
@@ -9,6 +9,7 @@ export const workspaceKeys = {
invitations: (wsId: string) => ["workspaces", wsId, "invitations"] as const,
myInvitations: () => ["invitations", "mine"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
};
@@ -43,6 +44,14 @@ export function agentListOptions(wsId: string) {
});
}
export function squadListOptions(wsId: string) {
return queryOptions<Squad[]>({
queryKey: workspaceKeys.squads(wsId),
queryFn: () => api.listSquads(),
enabled: !!wsId,
});
}
export function skillListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.skills(wsId),

View File

@@ -1,14 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import { Bot } from "lucide-react";
import { Bot, Users } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { MulticaIcon } from "./multica-icon";
interface ActorAvatarProps {
name: string;
initials: string;
avatarUrl?: string | null;
isAgent?: boolean;
isSystem?: boolean;
isSquad?: boolean;
size?: number;
className?: string;
}
@@ -18,12 +21,13 @@ function ActorAvatar({
initials,
avatarUrl,
isAgent,
isSystem,
isSquad,
size = 20,
className,
}: ActorAvatarProps) {
const [imgError, setImgError] = useState(false);
// Reset error state when URL changes (e.g. user uploads new avatar)
useEffect(() => {
setImgError(false);
}, [avatarUrl]);
@@ -32,7 +36,10 @@ function ActorAvatar({
<div
data-slot="avatar"
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
"inline-flex shrink-0 items-center justify-center font-medium overflow-hidden",
// Squads (a group, non-human) get a square tile so they don't read as
// a single person; everyone else stays round.
isSquad ? "rounded-md" : "rounded-full",
"bg-muted text-muted-foreground",
className
)}
@@ -46,8 +53,12 @@ function ActorAvatar({
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : isSystem ? (
<MulticaIcon noSpin style={{ width: size * 0.55, height: size * 0.55 }} />
) : isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : isSquad ? (
<Users style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials
)}

View File

@@ -2,6 +2,7 @@
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@multica/ui/lib/utils";
interface FileUploadButtonProps {
@@ -18,7 +19,9 @@ function FileUploadButton({
className,
size = "default",
}: FileUploadButtonProps) {
const { t } = useTranslation("ui");
const inputRef = useRef<HTMLInputElement>(null);
const attachLabel = t(($) => $.attach_file);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -36,8 +39,8 @@ function FileUploadButton({
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
aria-label="Attach file"
title="Attach file"
aria-label={attachLabel}
title={attachLabel}
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,

View File

@@ -1,4 +1,5 @@
import * as React from "react"
import { useTranslation } from "react-i18next"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
@@ -67,9 +68,10 @@ function PaginationPrevious({
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label="Go to previous page"
aria-label={t(($) => $.pagination_previous)}
size="default"
className={cn("pl-1.5!", className)}
{...props}
@@ -85,9 +87,10 @@ function PaginationNext({
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label="Go to next page"
aria-label={t(($) => $.pagination_next)}
size="default"
className={cn("pr-1.5!", className)}
{...props}

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useTranslation } from "react-i18next"
import { useIsMobile } from "@multica/ui/hooks/use-mobile"
import { cn } from "@multica/ui/lib/utils"
@@ -265,6 +266,7 @@ function SidebarTrigger({
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { t } = useTranslation("ui")
return (
<Button
@@ -280,13 +282,15 @@ function SidebarTrigger({
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">{t(($) => $.toggle_sidebar)}</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar, setWidth, setIsResizing } = useSidebar()
const { t } = useTranslation("ui")
const toggleLabel = t(($) => $.toggle_sidebar)
const didDragRef = React.useRef(false)
const dragRef = React.useRef<{ startX: number; startWidth: number } | null>(null)
@@ -330,11 +334,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label={toggleLabel}
tabIndex={-1}
onClick={handleClick}
onMouseDown={onMouseDown}
title="Toggle Sidebar"
title={toggleLabel}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",

View File

@@ -43,11 +43,19 @@ export function useScrollFade(
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
// ResizeObserver only fires on the container's own box. When children
// grow inside a flex/auto-height parent (e.g. async-loaded list items,
// collapsibles), scrollHeight changes but clientHeight does not — the
// mask would stay "none" until the user scrolls. MutationObserver on
// childList catches those content insertions.
const mo = new MutationObserver(update);
mo.observe(el, { childList: true, subtree: true });
return () => {
cancelAnimationFrame(frame);
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [ref, update]);

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
import { Copy, Check } from "lucide-react"
import { useTranslation } from "react-i18next"
import { Button } from "@multica/ui/components/ui/button"
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"
import { cn } from '@multica/ui/lib/utils'
@@ -61,6 +62,7 @@ export function CodeBlock({
className,
mode = 'full'
}: CodeBlockProps): React.JSX.Element {
const { t } = useTranslation("ui")
const [highlighted, setHighlighted] = React.useState<string | null>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [copied, setCopied] = React.useState(false)
@@ -178,7 +180,7 @@ export function CodeBlock({
{/* Language label + copy button */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
<span className="text-muted-foreground font-medium uppercase tracking-wide">
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
{resolvedLang !== 'text' ? resolvedLang : t(($) => $.plain_text)}
</span>
<Tooltip>
<TooltipTrigger
@@ -188,7 +190,7 @@ export function CodeBlock({
size="icon-xs"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
aria-label={t(($) => $.copy_code)}
>
{copied ? (
<Check className="size-3.5 text-success" />
@@ -198,7 +200,7 @@ export function CodeBlock({
</Button>
}
/>
<TooltipContent>Copy code</TooltipContent>
<TooltipContent>{t(($) => $.copy_code)}</TooltipContent>
</Tooltip>
</div>

View File

@@ -17,6 +17,7 @@
"./hooks/*": "./hooks/*.ts",
"./lib/utils": "./lib/utils.ts",
"./lib/data-table": "./lib/data-table.ts",
"./i18n-types": "./types/i18next.ts",
"./styles/tokens.css": "./styles/tokens.css",
"./styles/base.css": "./styles/base.css"
},
@@ -52,8 +53,10 @@
"vaul": "^1.1.2"
},
"peerDependencies": {
"i18next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"react-i18next": "catalog:"
},
"devDependencies": {
"@multica/tsconfig": "workspace:*",

View File

@@ -0,0 +1,35 @@
import "i18next";
// Local slice of the i18next augmentation that owns the `ui` namespace.
// The base augmentation lives in packages/views/i18n/resources-types.ts and
// declares everything else; this file contributes only the `ui` entry via
// declaration merging on the global `I18nResources` interface so
// packages/ui can typecheck the selector form standalone without depending
// on @multica/views.
//
// When both files are loaded together (in a consumer's typecheck program),
// the two augmentations compose: views contributes common/auth/... and ui
// contributes `ui`. No properties overlap, so the merge is conflict-free.
//
// The resource shape is mirrored from packages/views/locales/{en,zh-Hans}/ui.json.
// Drift between the JSON and these types is not caught by the locale parity
// test — if you add a key to ui.json, mirror it here.
declare global {
interface I18nResources {
ui: {
attach_file: string;
toggle_sidebar: string;
pagination_previous: string;
pagination_next: string;
copy_code: string;
plain_text: string;
};
}
}
declare module "i18next" {
interface CustomTypeOptions {
resources: I18nResources;
enableSelector: true;
}
}

View File

@@ -208,7 +208,7 @@ export function AgentOverviewPane({
);
}
// Centred, max-width container shared by every config tab. `h-full flex
// Padded, full-width container shared by every config tab. `h-full flex
// flex-col` lets a tab opt into "fill the viewport" by giving its root
// element `flex-1 min-h-0` (Instructions does this so the editor expands
// instead of pushing the Save row off-screen). Tabs that don't opt in
@@ -216,6 +216,6 @@ export function AgentOverviewPane({
// list) still scrolls via the parent's overflow-y-auto.
function TabContent({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
<div className="flex h-full flex-col p-4 md:p-6">{children}</div>
);
}

View File

@@ -280,35 +280,24 @@ export function AgentsPage() {
if (view === "archived" && archivedCount === 0) setView("active");
}, [view, archivedCount]);
const handleCreate = async (data: CreateAgentRequest) => {
const handleCreate = async (data: CreateAgentRequest): Promise<Agent> => {
const agent = await api.createAgent(data);
let cachedAgent = agent;
// When duplicating, carry the source agent's skill assignments over.
// Skills aren't part of CreateAgentRequest (they're managed via
// setAgentSkills) so the create endpoint can't take them inline; we
// do a follow-up call. Failure here doesn't abort the duplicate —
// the agent already exists and the user can re-attach skills from
// the detail page.
if (duplicateTemplate?.skills.length) {
try {
await api.setAgentSkills(agent.id, {
skill_ids: duplicateTemplate.skills.map((s) => s.id),
});
cachedAgent = { ...agent, skills: duplicateTemplate.skills };
} catch {
// Surfaced softly; the agent itself is fine.
}
}
// Skill follow-up is now owned by the dialog (it reads the user's
// form selection, which already includes the duplicate source's
// skills as a default when applicable). The dialog will call
// setAgentSkills after we return; we just have to surface the
// created agent so it can.
qc.setQueryData<Agent[]>(workspaceKeys.agents(wsId), (current = []) => {
const exists = current.some((a) => a.id === cachedAgent.id);
const exists = current.some((a) => a.id === agent.id);
return exists
? current.map((a) => (a.id === cachedAgent.id ? cachedAgent : a))
: [...current, cachedAgent];
? current.map((a) => (a.id === agent.id ? agent : a))
: [...current, agent];
});
setShowCreate(false);
setDuplicateTemplate(null);
navigation.push(paths.agentDetail(agent.id));
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
return agent;
};
const handleDuplicate = useCallback((agent: Agent) => {
@@ -467,6 +456,7 @@ export function AgentsPage() {
members={members}
currentUserId={currentUser?.id ?? null}
template={duplicateTemplate}
existingAgentNames={agents.map((a) => a.name)}
onClose={() => {
setShowCreate(false);
setDuplicateTemplate(null);

View File

@@ -0,0 +1,143 @@
"use client";
import { useRef, useState } from "react";
import { Camera, ImagePlus, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface AvatarPickerProps {
/** Current avatar URL. null when nothing chosen yet. */
value: string | null;
/** Fires after a successful upload — the parent stashes the URL for the
* create call. Re-fires with null when the user clears the choice. */
onChange: (url: string | null) => void;
/** Pixel size of the square. Defaults to 56 (h-14 / w-14), which lines
* up vertically with the Name + Description stack in the create-agent
* form so the two read as a single visual row. */
size?: number;
}
/**
* Compact avatar picker — a single square that lives next to the Name
* input in the create-agent form. Mirrors the visual language of
* agent-detail-inspector.tsx (Camera overlay on hover, file input behind
* the scenes), so users who've configured an avatar elsewhere in the app
* recognise the affordance immediately.
*
* No avatar yet → dashed placeholder with an ImagePlus icon.
* Has avatar → image fills the square, hover dims it with a Camera
* overlay for "click to change". A small × in the corner
* clears the choice.
*/
export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps) {
const { t } = useT("agents");
const fileInputRef = useRef<HTMLInputElement>(null);
const { upload, uploading } = useFileUpload(api);
const [previewError, setPreviewError] = useState(false);
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = ""; // allow re-selecting the same file
if (!file.type.startsWith("image/")) {
toast.error(t(($) => $.create_dialog.avatar.select_image_toast));
return;
}
try {
const result = await upload(file);
if (!result) return;
setPreviewError(false);
onChange(result.link);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t(($) => $.create_dialog.avatar.upload_failed_toast),
);
}
};
const hasValue = !!value && !previewError;
const dimensionStyle = { width: size, height: size };
return (
<div className="relative shrink-0" style={dimensionStyle}>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className={cn(
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
hasValue
? "border bg-muted"
: "border border-dashed bg-muted/40 hover:bg-muted",
)}
aria-label={
hasValue
? t(($) => $.create_dialog.avatar.change_aria)
: t(($) => $.create_dialog.avatar.upload_aria)
}
style={dimensionStyle}
>
{hasValue ? (
<img
src={value ?? undefined}
alt=""
className="h-full w-full object-cover"
onError={() => setPreviewError(true)}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ImagePlus className="h-5 w-5" />
)}
</div>
)}
{/* Hover overlay only when there's already an image — otherwise the
placeholder icon already invites the click. */}
{hasValue && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin text-white" />
) : (
<Camera className="h-4 w-4 text-white" />
)}
</div>
)}
</button>
{/* Tiny X to clear, only shown when there's a value. Positioned just
outside the avatar's top-right corner so it doesn't cover the
image. */}
{hasValue && !uploading && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange(null);
setPreviewError(false);
}}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
aria-label={t(($) => $.create_dialog.avatar.remove_aria)}
>
<X className="h-3 w-3" />
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFile}
/>
</div>
);
}

View File

@@ -0,0 +1,297 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { NavigationProvider, type NavigationAdapter } from "../../navigation";
import enCommon from "../../locales/en/common.json";
import enAgents from "../../locales/en/agents.json";
const navigationStub: NavigationAdapter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/",
searchParams: new URLSearchParams(),
getShareableUrl: (path: string) => path,
};
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// ModelDropdown talks to the api; the create dialog only needs it as a
// stand-in here, so swap it out.
vi.mock("./model-dropdown", () => ({
ModelDropdown: () => null,
}));
// Provider logos don't matter for these assertions but they pull in SVGs.
vi.mock("../../runtimes/components/provider-logo", () => ({
ProviderLogo: () => null,
}));
// Avatars hit the api for member metadata.
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: () => null,
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
import { CreateAgentDialog } from "./create-agent-dialog";
const ME = "user-me";
const OTHER = "user-other";
const members: MemberWithUser[] = [
{
id: "m-me",
user_id: ME,
workspace_id: "ws-1",
role: "member",
name: "Me",
email: "me@example.com",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
},
{
id: "m-other",
user_id: OTHER,
workspace_id: "ws-1",
role: "member",
name: "Other",
email: "other@example.com",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
},
];
function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
return {
id: "rt",
workspace_id: "ws-1",
daemon_id: null,
name: "Test Runtime",
runtime_mode: "local",
provider: "claude",
launch_header: "",
status: "online",
device_info: "host.local",
metadata: {},
owner_id: ME,
visibility: "private",
timezone: "UTC",
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
...overrides,
};
}
function makeTemplate(runtimeId: string): Agent {
return {
id: "agent-template",
workspace_id: "ws-1",
runtime_id: runtimeId,
name: "Template Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "private",
status: "idle",
max_concurrent_tasks: 1,
model: "",
owner_id: ME,
skills: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
};
}
function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const onCreate = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<WorkspaceSlugProvider slug="test-ws">
<NavigationProvider value={navigationStub}>
<CreateAgentDialog
runtimes={runtimes}
members={members}
currentUserId={ME}
template={template}
onClose={onClose}
onCreate={onCreate}
/>
</NavigationProvider>
</WorkspaceSlugProvider>
</QueryClientProvider>
</I18nProvider>,
);
// Without a `template`, the dialog opens on the blank-vs-template
// chooser. These tests target the manual form's runtime picker, so
// advance through the chooser to the form. Duplicate mode jumps
// straight to the form and doesn't render the chooser.
if (!template) {
fireEvent.click(screen.getByText(enAgents.create_dialog.chooser.blank_title));
}
return { onCreate, onClose };
}
describe("CreateAgentDialog runtime visibility gate", () => {
beforeEach(() => vi.clearAllMocks());
// Base UI Dialog renders into a portal on document.body and leaves
// focus-guard / inert wrapper divs around after the React tree unmounts.
// The auto-cleanup from @testing-library/react drops the container but
// not the portal residue, so two-tests-in-a-row queries see double
// matches ("All", "My Runtime"). Force cleanup + wipe body between tests.
afterEach(() => {
cleanup();
document.body.innerHTML = "";
});
it("disables another member's private runtime in the picker", () => {
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
owner_id: OTHER,
visibility: "private",
});
renderDialog([mine, othersPrivate]);
// Flip to "All" so other-owned runtimes show.
fireEvent.click(screen.getByText("All"));
// Open the picker.
fireEvent.click(
screen.getByText("My Runtime", { selector: "span.truncate" }),
);
const disabledRow = screen
.getByText("Others Private")
.closest("button") as HTMLButtonElement;
expect(disabledRow).not.toBeNull();
expect(disabledRow.disabled).toBe(true);
expect(disabledRow.title).toMatch(/Private runtime/i);
});
it("lets a plain member pick another member's public runtime", () => {
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
const othersPublic = makeRuntime({
id: "rt-others-public",
name: "Others Public",
owner_id: OTHER,
visibility: "public",
});
renderDialog([mine, othersPublic]);
fireEvent.click(screen.getByText("All"));
fireEvent.click(
screen.getByText("My Runtime", { selector: "span.truncate" }),
);
const publicRow = screen
.getByText("Others Public")
.closest("button") as HTMLButtonElement;
expect(publicRow).not.toBeNull();
expect(publicRow.disabled).toBe(false);
});
it("defaults the selected runtime to a usable one, not a locked private", () => {
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
owner_id: OTHER,
visibility: "private",
});
const mine = makeRuntime({
id: "rt-mine",
name: "My Runtime",
owner_id: ME,
visibility: "private",
});
renderDialog([othersPrivate, mine]);
// The trigger label shows the selected runtime name. The picker must
// not seed with the other-owned private runtime even if it sorted
// first in the input list.
expect(screen.queryByText("Others Private", { selector: "span.truncate" })).toBeNull();
expect(screen.getByText("My Runtime", { selector: "span.truncate" })).toBeInTheDocument();
});
it("in duplicate mode, does not pre-fill the template's runtime when it's now locked", async () => {
// Template runtime is owned by someone else and now private — the
// duplicate flow used to seed with it anyway, leaving the user with
// a Create button that 403s server-side. Now we fall back to the
// first usable runtime instead.
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
owner_id: OTHER,
visibility: "private",
});
const mine = makeRuntime({
id: "rt-mine",
name: "My Runtime",
owner_id: ME,
visibility: "private",
});
const template = makeTemplate("rt-others-private");
const { onCreate } = renderDialog([othersPrivate, mine], template);
expect(
screen.getByText("My Runtime", { selector: "span.truncate" }),
).toBeInTheDocument();
expect(
screen.queryByText("Others Private", { selector: "span.truncate" }),
).toBeNull();
// Sanity check: with a usable selection seeded, Create should submit.
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
});
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
// Edge case: template points at a locked runtime AND the workspace
// has no usable alternatives in scope. The defense-in-depth gate on
// the Create button must keep the user from submitting a 403.
const onlyOthersPrivate = makeRuntime({
id: "rt-only-others-private",
name: "Only Others Private",
owner_id: OTHER,
visibility: "private",
});
// Flip the picker to "All" so the locked runtime is at least
// visible — that's the scope where the selected-but-locked state
// can persist after the initial seed search returns nothing.
const template = makeTemplate("rt-only-others-private");
renderDialog([onlyOthersPrivate], template);
// The Create button is rendered by lucide-free CTA text "Create".
const createBtn = screen
.getAllByRole("button")
.find((b) => b.textContent === "Create");
expect(createBtn).toBeDefined();
expect((createBtn as HTMLButtonElement).disabled).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { Cloud, Monitor } from "lucide-react";
import { Cloud, Lock, Monitor } from "lucide-react";
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
import { ActorAvatar } from "../../../common/actor-avatar";
import {
@@ -45,18 +45,28 @@ export function RuntimePicker({
// Compute filtered list unconditionally — the early `!canEdit` return
// below would otherwise re-order this hook across renders.
const isDisabled = (r: AgentRuntime): boolean => {
if (!currentUserId) return false;
if (r.owner_id === currentUserId) return false;
return r.visibility !== "public";
};
const filtered = useMemo(() => {
const list =
filter === "mine" && currentUserId
? runtimes.filter((r) => r.owner_id === currentUserId)
: runtimes;
return [...list].sort((a, b) => {
if (a.owner_id === currentUserId && b.owner_id !== currentUserId)
return -1;
if (a.owner_id !== currentUserId && b.owner_id === currentUserId)
return 1;
const aMine = a.owner_id === currentUserId;
const bMine = b.owner_id === currentUserId;
if (aMine && !bMine) return -1;
if (!aMine && bMine) return 1;
const aDisabled = isDisabled(a);
const bDisabled = isDisabled(b);
if (!aDisabled && bDisabled) return -1;
if (aDisabled && !bDisabled) return 1;
return 0;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runtimes, filter, currentUserId]);
if (!canEdit) {
@@ -157,10 +167,12 @@ export function RuntimePicker({
filtered.map((rt) => {
const owner = getOwner(rt.owner_id);
const rtOnline = rt.status === "online";
const locked = isDisabled(rt);
const tooltip = [
rt.name,
owner ? t(($) => $.pickers.runtime_owned_by, { name: owner.name }) : null,
rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
locked ? t(($) => $.create_dialog.runtime_private_locked_tooltip) : null,
]
.filter(Boolean)
.join(" · ");
@@ -168,7 +180,11 @@ export function RuntimePicker({
<PickerItem
key={rt.id}
selected={rt.id === value}
onClick={() => void select(rt.id)}
disabled={locked}
onClick={() => {
if (locked) return;
void select(rt.id);
}}
tooltip={tooltip}
>
<ProviderLogo
@@ -185,6 +201,12 @@ export function RuntimePicker({
{t(($) => $.create_dialog.runtime_cloud_badge)}
</span>
)}
{locked && (
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
<Lock className="h-2.5 w-2.5" />
{t(($) => $.create_dialog.runtime_private_badge)}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{owner && (

View File

@@ -0,0 +1,116 @@
"use client";
import { useRef, useState } from "react";
import { ChevronDown, FileText, X } from "lucide-react";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface InstructionsEditorProps {
/** Markdown source. Used both as default value when expanded and as
* preview text when collapsed. */
value: string;
/** Fires on every keystroke (debounced inside ContentEditor). */
onChange: (value: string) => void;
/** Optional placeholder override. Defaults to the i18n "click to write"
* copy; the create dialog passes the duplicate-specific string for
* agents being cloned. */
placeholder?: string;
}
/**
* Collapsible Instructions field for the create-agent dialog. Stays compact
* until the user wants to write — most agents only need instructions when
* they're being authored carefully, not on every quick-create.
*
* Two states:
* collapsed → small clickable card, shows a preview of `value` (or the
* placeholder when empty). One click expands.
* expanded → full ContentEditor (markdown, bubble menu, mention support,
* attachment upload). "Collapse" button on the right of the
* header tucks it back; value is preserved.
*/
export function InstructionsEditor({
value,
onChange,
placeholder,
}: InstructionsEditorProps) {
const { t } = useT("agents");
const [expanded, setExpanded] = useState(false);
const editorRef = useRef<ContentEditorRef>(null);
const label = t(($) => $.create_dialog.instructions.label);
const resolvedPlaceholder =
placeholder ?? t(($) => $.create_dialog.instructions.placeholder_blank);
const expand = () => {
setExpanded(true);
// Focus on next tick so the editor mounts first.
setTimeout(() => editorRef.current?.focus(), 0);
};
if (!expanded) {
return (
<div>
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<button
type="button"
onClick={expand}
className="mt-1.5 flex w-full items-start gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<FileText className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
{value.trim() ? (
// Preview: first 2 lines of markdown, ellipsised.
<div className="line-clamp-2 whitespace-pre-wrap text-sm text-foreground/80">
{value}
</div>
) : (
<div className="text-sm text-muted-foreground">{resolvedPlaceholder}</div>
)}
</div>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
</button>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setExpanded(false)}
className="h-6 gap-1 px-2 text-xs"
>
<X className="h-3 w-3" />
{t(($) => $.create_dialog.instructions.collapse)}
</Button>
</div>
<div
className={cn(
"mt-1.5 rounded-lg border bg-card",
"focus-within:border-primary/40",
)}
>
<ContentEditor
ref={editorRef}
defaultValue={value}
onUpdate={onChange}
placeholder={t(($) => $.create_dialog.instructions.editor_placeholder)}
className="min-h-[160px] max-h-[320px] overflow-y-auto px-3 py-2.5 text-sm"
showBubbleMenu={true}
disableMentions={true}
/>
</div>
</div>
);
}

View File

@@ -1,10 +1,9 @@
"use client";
import { useState } from "react";
import { FileText, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent } from "@multica/core/types";
import type { Agent, SkillSummary } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import {
@@ -20,18 +19,19 @@ import {
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { useT } from "../../i18n";
import { SkillPickerList } from "./skill-picker-list";
/**
* Single source of truth for "attach a workspace skill to this agent".
* Used by both:
* - SkillsTab — full surface, "Add skill" button
* - Inspector → SkillAttach — inline dashed `+ Attach` chip
* "Attach workspace skills to this agent." Multi-select with explicit
* Confirm — earlier iterations attached on a single row click, which
* meant the user couldn't tick several skills at once and the dialog
* closed before they could review their choice.
*
* Owns the workspace-skill list query, the "what's still attachable" filter,
* the API call, and the optimistic invalidation. Callers only manage the
* open/close state — they don't repeat the attach logic.
* Already-attached skills are filtered out of the list entirely (vs.
* showing them disabled). When there are no remaining workspace skills
* to attach, the empty-state copy explains why, and the Confirm button
* is naturally disabled because nothing can be selected.
*/
export function SkillAddDialog({
agent,
@@ -45,34 +45,44 @@ export function SkillAddDialog({
const { t } = useT("agents");
const wsId = useWorkspaceId();
const qc = useQueryClient();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
const [saving, setSaving] = useState(false);
const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
const availableSkills = workspaceSkills.filter(
(s) => !agentSkillIds.has(s.id),
const attachedIds = useMemo(
() => new Set(agent.skills.map((s) => s.id)),
[agent.skills],
);
// Hide attached skills outright — the dialog is for adding new ones.
// If a user wants to see what's already on the agent, the SkillsTab
// list above shows it.
const availableSkills = useMemo(
() => workspaceSkills.filter((s) => !attachedIds.has(s.id)),
[workspaceSkills, attachedIds],
);
const trimmedQuery = query.trim().toLowerCase();
const filteredSkills = trimmedQuery
? availableSkills.filter((s) => {
const name = s.name.toLowerCase();
const description = s.description?.toLowerCase() ?? "";
return (
name.includes(trimmedQuery) || description.includes(trimmedQuery)
);
})
: availableSkills;
const handleOpenChange = (v: boolean) => {
if (!v) setQuery("");
if (!v) setSelectedIds(new Set());
onOpenChange(v);
};
const handleAdd = async (skillId: string) => {
const handleToggle = (skill: SkillSummary) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(skill.id)) next.delete(skill.id);
else next.add(skill.id);
return next;
});
};
const handleConfirm = async () => {
if (selectedIds.size === 0) return;
setSaving(true);
try {
const newIds = [...agent.skills.map((s) => s.id), skillId];
const newIds = [
...agent.skills.map((s) => s.id),
...selectedIds,
];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
handleOpenChange(false);
@@ -83,65 +93,46 @@ export function SkillAddDialog({
}
};
const showSearch = availableSkills.length > 0;
const noMatch = showSearch && filteredSkills.length === 0;
const count = selectedIds.size;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">{t(($) => $.tab_body.skills.add_dialog_title)}</DialogTitle>
<DialogTitle className="text-sm">
{t(($) => $.tab_body.skills.add_dialog_title)}
</DialogTitle>
<DialogDescription className="text-xs">
{t(($) => $.tab_body.skills.add_dialog_description)}
</DialogDescription>
</DialogHeader>
{showSearch && (
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
aria-label={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
className="pl-7"
/>
</div>
)}
<div className="max-h-64 space-y-1 overflow-y-auto">
{filteredSkills.map((skill) => (
<button
key={skill.id}
onClick={() => handleAdd(skill.id)}
disabled={saving}
className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{skill.name}</div>
{skill.description && (
<div className="truncate text-xs text-muted-foreground">
{skill.description}
</div>
)}
</div>
</button>
))}
{availableSkills.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.add_dialog_empty)}
</p>
)}
{noMatch && (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.add_dialog_no_match)}
</p>
)}
</div>
<SkillPickerList
skills={availableSkills}
selectedIds={selectedIds}
onToggle={handleToggle}
loading={isLoading}
emptyMessage={
workspaceSkills.length === 0
? t(($) => $.tab_body.skills.add_dialog_empty)
: t(($) => $.tab_body.skills.add_dialog_empty_partial)
}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
{t(($) => $.tab_body.skills.add_dialog_cancel)}
</Button>
<Button
onClick={handleConfirm}
disabled={count === 0 || saving}
>
{saving
? t(($) => $.tab_body.skills.add_dialog_saving)
: count > 0
? t(($) => $.tab_body.skills.add_dialog_confirm, { count })
: t(($) => $.tab_body.skills.add_dialog_confirm_default)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { ChevronDown, Plus, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { SkillSummary } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { skillListOptions } from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import { useT } from "../../i18n";
import { SkillPickerList } from "./skill-picker-list";
interface SkillMultiSelectProps {
/** Currently-selected skill IDs (controlled). */
selectedIds: ReadonlySet<string>;
/** Replaces the selection on every toggle. */
onChange: (next: Set<string>) => void;
}
/**
* Multi-select wrapper for the create-agent form. Collapsed by default;
* expands into a SkillPickerList configured for toggle behaviour
* (click adds to / removes from the local selection set).
*
* Shares its visual surface with SkillAddDialog via SkillPickerList —
* one component owns search + row rendering + indicators, so a tweak
* to either appears identically in both flows.
*/
export function SkillMultiSelect({
selectedIds,
onChange,
}: SkillMultiSelectProps) {
const { t } = useT("agents");
const wsId = useWorkspaceId();
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
const [expanded, setExpanded] = useState(selectedIds.size > 0);
const label = t(($) => $.create_dialog.skills_section.label);
const toggle = (skill: SkillSummary) => {
const next = new Set(selectedIds);
if (next.has(skill.id)) next.delete(skill.id);
else next.add(skill.id);
onChange(next);
};
if (!expanded) {
return (
<div>
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<button
type="button"
onClick={() => setExpanded(true)}
className="mt-1.5 flex w-full items-center gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<Plus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{selectedIds.size > 0
? t(($) => $.create_dialog.skills_section.selected, {
count: selectedIds.size,
})
: t(($) => $.create_dialog.skills_section.placeholder)}
</div>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
</button>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
{selectedIds.size > 0 ? (
<span className="ml-2 text-foreground/60">({selectedIds.size})</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setExpanded(false)}
className="h-6 gap-1 px-2 text-xs"
>
<X className="h-3 w-3" />
{t(($) => $.create_dialog.skills_section.collapse)}
</Button>
</div>
<div className="mt-1.5">
<SkillPickerList
skills={workspaceSkills}
selectedIds={selectedIds}
onToggle={toggle}
loading={isLoading}
emptyMessage={t(($) => $.create_dialog.skills_section.list_empty_multi)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { FileText, Search } from "lucide-react";
import type { SkillSummary } from "@multica/core/types";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { Input } from "@multica/ui/components/ui/input";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface SkillPickerListProps {
/** Skills to show. Callers filter (e.g. exclude already-attached
* skills in SkillAddDialog) before passing — this component just
* renders the rows. */
skills: readonly SkillSummary[];
/** Currently-toggled rows. Selected rows get a checked Checkbox and a
* subtle background; click toggles. */
selectedIds: ReadonlySet<string>;
/** Fires on every row click. Caller updates `selectedIds`. */
onToggle: (skill: SkillSummary) => void;
/** Show the search input at the top. Default true. */
searchable?: boolean;
/** Loading state for the skills query. */
loading?: boolean;
/** Caller-supplied empty / no-match copy. Falls back to generic i18n
* strings when omitted — the dialog and the create-form pass their
* own flavour-specific copy. */
emptyMessage?: string;
noMatchMessage?: string;
/** Outer-wrapper className. Defaults to `w-full`; callers pass
* e.g. `max-w-md` to constrain width. */
className?: string;
}
/**
* Headless multi-select list of workspace skills. Used by both
* SkillAddDialog (filtered to unattached skills) and SkillMultiSelect
* (create-form selection). One surface owns row layout, the search
* input, empty/loading states, and the shadcn Checkbox indicator, so
* tweaks land in one place.
*
* Rows truncate the name + description columns inside `flex-1 min-w-0`
* so long text doesn't push the Checkbox out of view.
*/
export function SkillPickerList({
skills,
selectedIds,
onToggle,
searchable = true,
loading = false,
emptyMessage,
noMatchMessage,
className,
}: SkillPickerListProps) {
const { t } = useT("agents");
const [query, setQuery] = useState("");
const trimmedQuery = query.trim().toLowerCase();
const filtered = trimmedQuery
? skills.filter((s) => {
const name = s.name.toLowerCase();
const description = s.description?.toLowerCase() ?? "";
return name.includes(trimmedQuery) || description.includes(trimmedQuery);
})
: skills;
const resolvedEmpty =
emptyMessage ?? t(($) => $.create_dialog.skills_section.list_empty_default);
const resolvedNoMatch =
noMatchMessage ?? t(($) => $.create_dialog.skills_section.list_no_match);
return (
<div className={cn("w-full overflow-hidden rounded-lg border bg-card", className)}>
{searchable && skills.length > 0 && (
<div className="border-b p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(($) => $.create_dialog.skills_section.search_placeholder)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
)}
<div className="max-h-64 space-y-0.5 overflow-y-auto p-1.5">
{loading ? (
<div className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.create_dialog.skills_section.list_loading)}
</div>
) : skills.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedEmpty}</div>
) : filtered.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedNoMatch}</div>
) : (
filtered.map((skill) => {
const isSelected = selectedIds.has(skill.id);
return (
<button
key={skill.id}
type="button"
onClick={() => onToggle(skill)}
aria-pressed={isSelected}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors",
isSelected ? "bg-accent" : "hover:bg-accent/50",
)}
>
{/* Indicator only — the wrapping <button> handles clicks,
so the Checkbox is non-interactive on its own. We
pass `checked` so the visual matches the row state. */}
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none"
/>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{skill.name}</div>
{skill.description ? (
<div className="truncate text-xs text-muted-foreground">
{skill.description}
</div>
) : null}
</div>
</button>
);
})
)}
</div>
</div>
);
}

View File

@@ -24,17 +24,16 @@ export function SkillsTab({
const qc = useQueryClient();
const wsId = useWorkspaceId();
// Same query the SkillAddDialog uses (TanStack Query dedupes by key, so
// this isn't an extra request) — used here only to grey out the "Add skill"
// button when there's nothing left to attach.
// this isn't an extra request) — used here only to grey out the "Add
// skill" button when the workspace has zero skills total. When skills
// exist but are all already attached, we still open the dialog: it
// filters out attached skills and renders a localised "no more skills
// to add" empty state, which is more useful than a mysterious
// greyed-out button.
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [removing, setRemoving] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
const availableCount = workspaceSkills.filter(
(s) => !agentSkillIds.has(s.id),
).length;
const handleRemove = async (skillId: string) => {
setRemoving(true);
try {
@@ -60,7 +59,7 @@ export function SkillsTab({
variant="outline"
size="sm"
onClick={() => setShowAdd(true)}
disabled={availableCount === 0}
disabled={workspaceSkills.length === 0}
className="shrink-0"
>
<Plus className="h-3 w-3" />
@@ -84,7 +83,7 @@ export function SkillsTab({
<p className="mt-1 max-w-xs text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.empty_hint)}
</p>
{availableCount > 0 && (
{workspaceSkills.length > 0 && (
<Button
onClick={() => setShowAdd(true)}
size="sm"

View File

@@ -0,0 +1,173 @@
"use client";
import { Check, ChevronRight, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { agentTemplateDetailOptions } from "@multica/core/agents/queries";
import type { AgentTemplateSummary } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
import { getAccentClass, getTemplateIcon } from "./template-picker";
interface TemplateDetailProps {
template: AgentTemplateSummary;
/** Fired when the user clicks "Use this template" — the dialog calls
* the create API and navigates to the new agent. */
onUse: (template: AgentTemplateSummary) => void;
/** True while the parent's create request is in flight; we disable the
* Use button so the user can't double-click. */
creating?: boolean;
/** Upstream URLs the server reported as unreachable on the most recent
* create attempt. Surfaces an inline error banner so the user knows
* *why* Create didn't navigate. The detail step is the only place
* this banner can render — `quickCreateFromTemplate` fires from here
* and never advances to a different step on failure. */
failedURLs?: readonly string[] | null;
}
/**
* Step 3 of the create-agent flow: a read-only preview of the picked
* template — instructions, skill list with cached descriptions, and a
* "Use this template" CTA at the bottom. Clicking Use kicks off a
* one-shot create with default settings (no form step in between).
*
* Instructions come from the lazy-fetched detail endpoint (the picker
* only carries the summary). Cached through TanStack Query keyed by
* slug with `staleTime: Infinity`, so navigating back and forth between
* picker and detail doesn't re-fetch. Visual rhythm matches the picker
* card so the transition feels seamless.
*/
export function TemplateDetail({
template,
onUse,
creating = false,
failedURLs,
}: TemplateDetailProps) {
const { t } = useT("agents");
const { data: detail, isLoading, error } = useQuery(
agentTemplateDetailOptions(template.slug),
);
const Icon = getTemplateIcon(template.icon);
const accentClass = getAccentClass(template.accent);
return (
<>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl p-6">
{/* failedURLs banner — sits above the header so it's the first
thing the user sees after the spinner clears on a 422. */}
{failedURLs && failedURLs.length > 0 && (
<div className="mb-5 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
<div className="font-medium text-destructive">
{t(($) => $.create_dialog.template_failure.title)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t(($) => $.create_dialog.template_failure.body)}
</div>
<ul className="mt-2 space-y-0.5 text-xs">
{failedURLs.map((u) => (
<li key={u} className="break-all font-mono">
{u}
</li>
))}
</ul>
</div>
)}
{/* Header: icon + name + description. Same rhythm as the picker
card so the user reads the transition as "the same item,
expanded". */}
<div className="flex items-start gap-3">
<div className={cn("flex h-12 w-12 shrink-0 items-center justify-center rounded-lg", accentClass)}>
<Icon className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold">{template.name}</h2>
<p className="mt-0.5 text-sm text-muted-foreground">{template.description}</p>
{template.category ? (
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{template.category}
</div>
) : null}
</div>
</div>
{/* Skill list — always visible (summary has cached descriptions) */}
<section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(($) => $.create_dialog.template_detail.skill_count, {
count: template.skills.length,
})}
</h3>
<ul className="mt-3 space-y-2">
{template.skills.map((s) => (
<li
key={s.source_url}
className="rounded-lg border bg-card px-3 py-2.5"
>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-success" />
<span className="font-mono text-xs font-medium">{s.cached_name}</span>
</div>
{s.cached_description ? (
<p className="mt-1 ml-6 text-xs text-muted-foreground">
{s.cached_description}
</p>
) : null}
</li>
))}
</ul>
</section>
{/* Instructions — lazy fetch + loading/error states */}
<section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(($) => $.create_dialog.template_detail.instructions_label)}
</h3>
<div className="mt-3 rounded-lg border bg-muted/30 px-4 py-3">
{isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t(($) => $.create_dialog.template_detail.instructions_loading)}
</div>
) : error ? (
<div className="text-xs text-destructive">
{error instanceof Error
? error.message
: t(($) => $.create_dialog.template_detail.load_failed)}
</div>
) : (
<pre className="max-h-60 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/80">
{detail?.instructions ?? ""}
</pre>
)}
</div>
</section>
</div>
</div>
{/* Sticky CTA footer — click Use kicks off the create API call;
parent shows a creating spinner and navigates on success. */}
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
<Button
onClick={() => onUse(template)}
disabled={creating}
className="gap-1.5"
>
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t(($) => $.create_dialog.template_detail.creating)}
</>
) : (
<>
{t(($) => $.create_dialog.template_detail.use)}
<ChevronRight className="h-4 w-4" />
</>
)}
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,203 @@
"use client";
import { useMemo } from "react";
import {
Brush,
ChevronRight,
FileText,
FlaskConical,
LayoutDashboard,
ListChecks,
Loader2,
Megaphone,
Palette,
PenLine,
Presentation,
Search,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { agentTemplateListOptions } from "@multica/core/agents/queries";
import type { AgentTemplateSummary } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface TemplatePickerProps {
/** Fired when a template card is clicked. The dialog advances to the
* detail step (which shows instructions + skills + Use button). */
onSelect: (template: AgentTemplateSummary) => void;
}
/**
* Step 2 of the create-agent flow: a 2-column grid of template cards,
* grouped by `category`. Clicking a card moves to the detail step.
*
* Templates are a static catalog (workspace-independent, only changes on
* server deploy), so the catalog is loaded through TanStack Query with
* `staleTime: Infinity` — re-opening the picker hits the cache instantly
* and there's no per-mount refetch.
*
* Icons and accent colors come from the template JSON itself (`icon` is a
* lucide-react name, `accent` is a Multica semantic token). Resolved
* through static maps (ICONS / ACCENTS) so Tailwind can JIT-scan every
* class variant — dynamic `bg-${accent}/10` strings would silently not
* generate.
*/
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
const { t } = useT("agents");
const { data: templates = [], isLoading, error } = useQuery(
agentTemplateListOptions(),
);
// Group by category. Templates without a category fall into the
// localised "Other" bucket so they still render. Preserves the load
// order within each group for deterministic UI (matches the
// alphabetic-by-filename order the loader uses on the server).
const otherCategory = t(($) => $.create_dialog.template_picker.other_category);
const groups = useMemo(() => {
const byCategory = new Map<string, AgentTemplateSummary[]>();
for (const tmpl of templates) {
const key = tmpl.category?.trim() ? tmpl.category : otherCategory;
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key)!.push(tmpl);
}
return Array.from(byCategory.entries());
}, [templates, otherCategory]);
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="text-sm text-destructive">
{error instanceof Error
? error.message
: t(($) => $.create_dialog.template_picker.load_failed)}
</div>
</div>
);
}
if (templates.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="text-sm text-muted-foreground">
{t(($) => $.create_dialog.template_picker.empty)}
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-6 p-6">
{groups.map(([category, tmpls]) => (
<section key={category}>
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{category}
</h2>
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
{tmpls.map((tmpl) => (
<TemplateCard
key={tmpl.slug}
template={tmpl}
onClick={() => onSelect(tmpl)}
/>
))}
</div>
</section>
))}
</div>
</div>
);
}
interface TemplateCardProps {
template: AgentTemplateSummary;
onClick: () => void;
}
function TemplateCard({ template, onClick }: TemplateCardProps) {
const { t } = useT("agents");
const Icon = ICONS[template.icon ?? ""] ?? FileText;
const accentClass = ACCENTS[template.accent ?? ""] ?? ACCENTS.muted;
return (
<button
type="button"
onClick={onClick}
className="group flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
accentClass,
)}
>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="truncate text-sm font-semibold">{template.name}</span>
<ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</p>
<div className="mt-2.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t(($) => $.create_dialog.template_card.skills, {
count: template.skills.length,
})}
</div>
</div>
</button>
);
}
// --- Static maps so Tailwind's JIT scanner picks up every variant ---
/** Lucide icon name → component. Add new entries when shipping templates
* that use icons not yet listed here. Unknown names fall back to FileText. */
const ICONS: Record<string, LucideIcon> = {
Search,
Palette,
FileText,
FlaskConical,
Sparkles,
ListChecks,
Brush,
PenLine,
Megaphone,
Presentation,
LayoutDashboard,
};
/** Semantic accent → Tailwind class string. The class strings are written
* out verbatim so JIT scans them; dynamic `bg-${name}/10` would not be
* generated. Mirrors the conventions in runtime-columns.tsx /
* usage-section.tsx (existing uses of these tokens). */
const DEFAULT_ACCENT = "bg-muted text-muted-foreground";
const ACCENTS: Record<string, string> = {
info: "bg-info/10 text-info",
success: "bg-success/10 text-success",
warning: "bg-warning/10 text-warning",
primary: "bg-primary/10 text-primary",
secondary: "bg-secondary text-secondary-foreground",
muted: DEFAULT_ACCENT,
};
/** Exposed for the detail / form steps so they can render the same icon
* badge as the picker card. Keeps visual continuity across steps. */
export function getTemplateIcon(iconName: string | undefined): LucideIcon {
return ICONS[iconName ?? ""] ?? FileText;
}
export function getAccentClass(accent: string | undefined): string {
return ACCENTS[accent ?? ""] ?? DEFAULT_ACCENT;
}

View File

@@ -0,0 +1,209 @@
import { forwardRef, useRef, useImperativeHandle } from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enChat from "../../locales/en/chat.json";
const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
// Track drop-zone callbacks so the test can simulate a real drop.
const dropHandlers = vi.hoisted(() => ({
onDrop: null as null | ((files: File[]) => void),
}));
vi.mock("../../editor", () => ({
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
dropHandlers.onDrop = onDrop;
return { isDragOver: false, dropZoneProps: { "data-testid": "drop-zone" } };
},
FileDropOverlay: () => null,
ContentEditor: forwardRef(function MockContentEditor(
{
defaultValue,
onUpdate,
placeholder,
onUploadFile,
}: {
defaultValue?: string;
onUpdate?: (md: string) => void;
placeholder?: string;
onUploadFile?: (file: File) => Promise<{ id: string; link: string; filename: string } | null>;
},
ref: React.Ref<unknown>,
) {
const valueRef = useRef<string>(defaultValue ?? "");
const uploadingRef = useRef(0);
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => {
valueRef.current = "";
},
blur: () => {},
focus: () => {},
uploadFile: async (file: File) => {
uploadingRef.current += 1;
try {
const result = await onUploadFile?.(file);
if (result) {
valueRef.current = `${valueRef.current}![](${result.link})`.trim();
onUpdate?.(valueRef.current);
}
} finally {
uploadingRef.current = Math.max(0, uploadingRef.current - 1);
}
},
hasActiveUploads: () => uploadingRef.current > 0,
}));
return (
<textarea
data-testid="editor"
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
onUpdate?.(e.target.value);
}}
/>
);
}),
}));
// Mock chat store with an in-memory implementation that supports both
// (selector) calls and getState().
vi.mock("@multica/core/chat", () => {
const state = {
activeSessionId: null as string | null,
selectedAgentId: "agent-1",
inputDrafts: {} as Record<string, string>,
focusMode: false,
setInputDraft: vi.fn(),
clearInputDraft: vi.fn(),
};
return {
DRAFT_NEW_SESSION: "__draft_new__",
useChatStore: Object.assign(
(selector?: (s: typeof state) => unknown) =>
selector ? selector(state) : state,
{ getState: () => state },
),
};
});
import { ChatInput } from "./chat-input";
function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}) {
const onSend = props.onSend ?? vi.fn();
const onUploadFile =
props.onUploadFile ??
vi.fn(async (_file: File) => ({
id: "att-1",
link: "https://cdn.example/att-1.png",
filename: "img.png",
}));
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatInput onSend={onSend} onUploadFile={onUploadFile} agentName="Multica" {...props} />
</I18nProvider>,
);
return { onSend, onUploadFile };
}
describe("ChatInput attachment wiring", () => {
it("routes dropped files through the editor's upload handler", async () => {
const { onUploadFile } = renderInput();
expect(dropHandlers.onDrop).not.toBeNull();
const file = new File(["x"], "drop.png", { type: "image/png" });
dropHandlers.onDrop?.([file]);
// Microtask: the mock editor awaits onUploadFile before mutating its value.
await Promise.resolve();
await Promise.resolve();
expect(onUploadFile).toHaveBeenCalledWith(file);
});
it("passes attachment_ids to onSend for uploads still referenced in the content", async () => {
const onSend = vi.fn();
const onUploadFile = vi.fn(async (_file: File) => ({
id: "att-42",
link: "https://cdn.example/att-42.png",
filename: "x.png",
}));
renderInput({ onSend, onUploadFile });
// Simulate the drop → editor.uploadFile → onUploadFile happy path. The
// mock editor appends the markdown link into its value and calls
// onUpdate so the input flips out of the empty state.
const file = new File(["x"], "drop.png", { type: "image/png" });
dropHandlers.onDrop?.([file]);
// Wait for the submit button to become enabled (onUpdate has fired and
// React has re-rendered). SubmitButton has no aria-label, so we pick
// the last action button on the bar (FileUploadButton, SubmitButton).
let sendButton: HTMLElement;
await waitFor(() => {
const buttons = screen.getAllByRole("button");
sendButton = buttons[buttons.length - 1]!;
expect(sendButton).not.toBeDisabled();
});
fireEvent.click(sendButton!);
expect(onSend).toHaveBeenCalledTimes(1);
const [, ids] = onSend.mock.calls[0]!;
expect(ids).toEqual(["att-42"]);
});
it("disables send while an upload is in flight, re-enables after it resolves", async () => {
let resolveUpload: (v: { id: string; link: string; filename: string }) => void;
const uploadPromise = new Promise<{ id: string; link: string; filename: string }>((res) => {
resolveUpload = res;
});
const onSend = vi.fn();
const onUploadFile = vi.fn(() => uploadPromise);
renderInput({ onSend, onUploadFile });
// Give the editor some text so isEmpty=false — this isolates the
// disabled state to the pending-upload condition (otherwise both
// checks would fire and the test couldn't tell them apart).
fireEvent.change(screen.getByTestId("editor"), { target: { value: "preview text" } });
const file = new File(["x"], "slow.png", { type: "image/png" });
dropHandlers.onDrop?.([file]);
// While the upload is pending the SubmitButton must be disabled.
// Bypassing this would send the message with the attachment id
// missing from the body.
await waitFor(() => {
const buttons = screen.getAllByRole("button");
const sendButton = buttons[buttons.length - 1]!;
expect(sendButton).toBeDisabled();
});
resolveUpload!({
id: "att-slow",
link: "https://cdn.example/att-slow.png",
filename: "slow.png",
});
let sendButton: HTMLElement;
await waitFor(() => {
const buttons = screen.getAllByRole("button");
sendButton = buttons[buttons.length - 1]!;
expect(sendButton).not.toBeDisabled();
});
fireEvent.click(sendButton!);
expect(onSend).toHaveBeenCalledTimes(1);
const [, ids] = onSend.mock.calls[0]!;
expect(ids).toEqual(["att-slow"]);
});
it("does not render the file upload button when onUploadFile is omitted", () => {
renderInput({ onUploadFile: undefined });
// FileUploadButton renders an icon button labelled by its tooltip — when
// upload wiring is absent the chat input falls back to "submit + extras"
// only. Probe by counting buttons: with no upload, only the submit
// button is in the action row.
const buttons = screen.getAllByRole("button");
// The agent picker / context anchor adornments may render zero buttons
// in this test (no leftAdornment passed). So a single button = submit.
expect(buttons.length).toBe(1);
});
});

Some files were not shown because too many files have changed in this diff Show More