Compare commits

..

13 Commits

Author SHA1 Message Date
Jiayuan Zhang
ef0ee819a6 fix(projects): reject scp-like URLs with '@' after ':' to avoid panic
isValidGitRepoURL indexed '@' and ':' independently, then sliced
s[at+1 : colon]. For inputs without '://' where '@' appears after the
first ':' (e.g. `host:org/repo@branch`), `at+1 > colon` triggered a
slice-bounds panic instead of a 400. Guard the slice and treat such
inputs as malformed.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 23:00:31 +08:00
Jiayuan Zhang
65cc376f11 fix(projects): accept SSH repo URLs for github_repo resources (#2484)
The project resource validator rejected anything that wasn't http(s), so
workspace repos configured with an SSH remote (ssh:// or the scp-like
`git@host:owner/repo.git` shorthand) could not be attached to a project.
Both forms are valid git remotes and the daemon hands the URL straight to
`git clone`, so the API has no reason to require https specifically.

Relax the validator to accept http/https/ssh/git schemes and the scp-like
shorthand, while still rejecting pasted garbage (no scheme, missing host,
missing path, ftp://, file://, etc.).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 22:48:55 +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
36 changed files with 1146 additions and 340 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

@@ -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

@@ -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:
```env
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 上:
```env
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

@@ -284,6 +284,32 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
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",

View File

@@ -284,6 +284,32 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
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",

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

@@ -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

@@ -22,6 +22,7 @@ export function useActorName() {
const getActorName = (type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
if (type === "system") return "Multica";
return "System";
};

View File

@@ -3,12 +3,14 @@
import { useState, useEffect } from "react";
import { Bot } 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;
size?: number;
className?: string;
}
@@ -18,6 +20,7 @@ function ActorAvatar({
initials,
avatarUrl,
isAgent,
isSystem,
size = 20,
className,
}: ActorAvatarProps) {
@@ -46,6 +49,8 @@ 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 }} />
) : (

View File

@@ -53,6 +53,7 @@ export function ActorAvatar({
initials={getActorInitials(actorType, actorId)}
avatarUrl={getActorAvatarUrl(actorType, actorId)}
isAgent={actorType === "agent"}
isSystem={actorType === "system"}
size={size}
className={className}
/>

View File

@@ -15,7 +15,7 @@
* the page.
*/
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { Maximize2 } from "lucide-react";
import { useT } from "../i18n";
@@ -113,6 +113,60 @@ function getMermaidLayout(svg: string): MermaidLayout {
return {};
}
// Default skeleton height while Mermaid loads + renders for the first time
// in this session. Picked to absorb most issue-detail diagrams without
// excessive empty space; web.dev's CLS guidance recommends reserving any
// such space upfront so async content doesn't shift surrounding layout.
const MERMAID_SKELETON_HEIGHT_PX = 280;
const MERMAID_LAYOUT_CACHE_PREFIX = "multica:mermaid:layout:";
// DJB2 — small, fast, sufficient for sessionStorage cache keys. The chart
// text itself is too unwieldy as a key (length, special chars), and a
// crypto-strength hash would have to be async.
function hashChart(chart: string): string {
let hash = 5381;
for (let i = 0; i < chart.length; i++) {
hash = ((hash << 5) + hash) ^ chart.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
function readCachedLayout(chart: string): MermaidLayout | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(
MERMAID_LAYOUT_CACHE_PREFIX + hashChart(chart),
);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed?.width === "number" &&
typeof parsed?.height === "number" &&
parsed.width > 0 &&
parsed.height > 0
) {
return { width: parsed.width, height: parsed.height };
}
return null;
} catch {
return null;
}
}
function writeCachedLayout(chart: string, layout: MermaidLayout): void {
if (typeof window === "undefined") return;
if (!layout.width || !layout.height) return;
try {
window.sessionStorage.setItem(
MERMAID_LAYOUT_CACHE_PREFIX + hashChart(chart),
JSON.stringify({ width: layout.width, height: layout.height }),
);
} catch {
// Quota exceeded or storage disabled — degrade silently; we still
// render correctly, just without the zero-shift optimisation.
}
}
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
@@ -200,7 +254,11 @@ export function MermaidDiagram({ chart }: { chart: string }) {
const themeVersion = useThemeVersion();
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
const [layout, setLayout] = useState<MermaidLayout>({});
// Lazy initial value: if we've rendered this exact chart already in the
// current session, the cached layout lets us reserve correct space on the
// very first paint — eliminating the 0px → real-height shift that breaks
// deep-link scroll positioning and ambient reading position.
const [layout, setLayout] = useState<MermaidLayout>(() => readCachedLayout(chart) ?? {});
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
@@ -212,7 +270,10 @@ export function MermaidDiagram({ chart }: { chart: string }) {
setError(null);
setSandboxedDocument(null);
setExpandedDocument(null);
setLayout({});
// Seed layout from cache (if any) so the skeleton sizes correctly
// even when `chart` changes after mount — the lazy useState above
// only fires once.
setLayout(readCachedLayout(chart) ?? {});
const mermaid = await getMermaid();
mermaid.initialize({
startOnLoad: false,
@@ -222,7 +283,9 @@ export function MermaidDiagram({ chart }: { chart: string }) {
});
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
if (!cancelled) {
setLayout(getMermaidLayout(renderedSvg));
const measured = getMermaidLayout(renderedSvg);
setLayout(measured);
writeCachedLayout(chart, measured);
setSandboxedDocument(
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
);
@@ -255,8 +318,21 @@ export function MermaidDiagram({ chart }: { chart: string }) {
);
}
// While the iframe is not yet ready, hold the container at the skeleton
// height (cached real height when available, fallback default otherwise).
// Once the iframe renders, drop the min-height — the iframe's own height
// drives layout. If the cache was right, this transition is zero-shift.
const containerStyle: CSSProperties | undefined = sandboxedDocument
? undefined
: { minHeight: layout.height ?? MERMAID_SKELETON_HEIGHT_PX };
return (
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
<div
ref={containerRef}
className="mermaid-diagram"
aria-label="Mermaid diagram"
style={containerStyle}
>
{sandboxedDocument ? (
<>
<iframe

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent as rtlFireEvent, render, screen, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { AgentTask } from "@multica/core/types/agent";
import enCommon from "../../locales/en/common.json";
@@ -251,6 +251,53 @@ describe("AgentLiveCard queued rendering", () => {
expect(screen.getByText("Stop")).toBeTruthy();
});
it("Stop button opens a confirm dialog and only calls cancelTask after the user confirms", async () => {
const runningTask = makeTask("task-r", { status: "running" });
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [runningTask] });
mockApi.cancelTask.mockResolvedValue(undefined);
renderCard();
await waitFor(() => {
expect(screen.getByText("Stop")).toBeTruthy();
});
// First click should not hit the API — it only opens the confirm.
await act(async () => {
rtlFireEvent.click(screen.getByText("Stop"));
});
expect(mockApi.cancelTask).not.toHaveBeenCalled();
expect(screen.getByText(/Stop this task\?/)).toBeTruthy();
// Confirm — now the cancel fires.
await act(async () => {
rtlFireEvent.click(screen.getByRole("button", { name: "Stop task" }));
});
expect(mockApi.cancelTask).toHaveBeenCalledWith("issue-1", "task-r");
});
it("Stop confirm dialog dismisses without cancelling when the user picks Keep running", async () => {
const runningTask = makeTask("task-r", { status: "running" });
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [runningTask] });
mockApi.cancelTask.mockResolvedValue(undefined);
renderCard();
await waitFor(() => {
expect(screen.getByText("Stop")).toBeTruthy();
});
await act(async () => {
rtlFireEvent.click(screen.getByText("Stop"));
});
expect(screen.getByText(/Stop this task\?/)).toBeTruthy();
await act(async () => {
rtlFireEvent.click(screen.getByRole("button", { name: "Keep running" }));
});
expect(mockApi.cancelTask).not.toHaveBeenCalled();
});
it("running tasks sort above queued tasks so the sticky slot stays on the active one", async () => {
const runningTask = makeTask("task-r", { status: "running" });
const queuedTask = makeTask("task-q", {

View File

@@ -15,6 +15,7 @@ import {
type TimelineItem,
} from "../../common/task-transcript";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
// AgentLiveCard renders a sticky banner at the top of the issue's main
// column for every active task. Each banner shows "agent X is working",
@@ -274,6 +275,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
const { t } = useT("issues");
const [elapsed, setElapsed] = useState("");
const [cancelling, setCancelling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const isQueued = task.status === "queued";
@@ -299,6 +301,11 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
}
}, [task.id, issueId, cancelling, t]);
const requestCancel = useCallback(() => {
if (cancelling) return;
setConfirmOpen(true);
}, [cancelling]);
const toolCount = items.filter((i) => i.type === "tool_use").length;
// Queued tasks render with a non-spinning Clock and dimmer accent so the
@@ -344,7 +351,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
/>
)}
<button
onClick={handleCancel}
onClick={requestCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
title={t(($) => $.agent_live.stop_tooltip)}
@@ -354,6 +361,12 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
</button>
</div>
</div>
<TerminateTaskConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={() => void handleCancel()}
showRunningNote={!isQueued}
/>
</div>
);
}

View File

@@ -663,7 +663,7 @@ function CommentCardImpl({
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} data-comment-id={reply.id} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<CommentRow
issueId={issueId}
entry={reply}

View File

@@ -17,6 +17,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { TranscriptButton } from "../../common/task-transcript";
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
// Mask gradient that fades the trigger-summary text into transparency at
// the right edge. Mirrors the pattern used by the desktop tab bar
@@ -255,6 +256,7 @@ function useStatusLabel(status: AgentTask["status"]): string {
function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
const { t } = useT("issues");
const [cancelling, setCancelling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const tone = STATUS_TONE[task.status];
const label = useStatusLabel(task.status);
const trigger = useTriggerText(task);
@@ -275,6 +277,11 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
}
};
const requestCancel = () => {
if (cancelling) return;
setConfirmOpen(true);
};
return (
<RowShell task={task}>
<TriggerText text={trigger} />
@@ -298,7 +305,7 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
render={
<button
type="button"
onClick={handleCancel}
onClick={requestCancel}
disabled={cancelling}
aria-label={t(($) => $.execution_log.cancel_task_aria)}
/>
@@ -314,6 +321,12 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
<TooltipContent>{t(($) => $.execution_log.cancel_task_tooltip)}</TooltipContent>
</Tooltip>
</RowActions>
<TerminateTaskConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={() => void handleCancel()}
showRunningNote={task.status === "running" || task.status === "dispatched"}
/>
</RowShell>
);
}

View File

@@ -287,14 +287,14 @@ vi.mock("@multica/core/issues/stores", () => ({
// Mock react-virtuoso: jsdom has no real layout, so the real Virtuoso would
// compute a 0-height viewport and render nothing. The mock renders every item
// inline, which matches how the unvirtualized .map used to behave and keeps
// existing assertions (`getByText('Started working on this')` etc.) working.
// inline so id="comment-..." nodes are always present in the DOM — this
// matches the production cold-path where `initialItemCount` force-mounts
// items[0..targetIdx], giving the native scrollIntoView a real target.
//
// scrollToIndexSpy: the deep-link logic now uses Virtuoso's own
// scrollToIndex API instead of native el.scrollIntoView (native diverges
// from Virtuoso's internal scrollTop model, petyosi #1083). The mock
// exposes a spy so we can assert deep-link landing in tests.
const scrollToIndexSpy = vi.hoisted(() => vi.fn());
// scrollIntoViewSpy: we spy on Element.prototype.scrollIntoView (jsdom no-ops
// it by default) so tests can assert the deep-link effect dispatched a
// native scroll on the target node.
const scrollIntoViewSpy = vi.hoisted(() => vi.fn());
vi.mock("react-virtuoso", () => ({
Virtuoso: forwardRef(function MockVirtuoso(
@@ -302,9 +302,10 @@ vi.mock("react-virtuoso", () => ({
ref: any,
) {
useImperativeHandle(ref, () => ({
scrollToIndex: scrollToIndexSpy,
// Real Virtuoso exposes more, but the deep-link path only needs
// scrollToIndex. Other call sites would fail loudly if added later.
// Real Virtuoso ref methods are not exercised by tests in this file
// since the cold-path uses native scrollIntoView on the DOM node.
scrollIntoView: vi.fn(),
scrollToIndex: vi.fn(),
}));
return (
<div data-testid="virtuoso-mock">
@@ -316,6 +317,17 @@ vi.mock("react-virtuoso", () => ({
}),
}));
// jsdom's HTMLElement.prototype.scrollIntoView is a no-op stub; replace it
// with a spy so the deep-link effect's call can be observed.
beforeEach(() => {
scrollIntoViewSpy.mockClear();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
writable: true,
value: scrollIntoViewSpy,
});
});
// Mock modals
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
@@ -597,30 +609,26 @@ describe("IssueDetail (shared)", () => {
});
describe("highlightCommentId scroll-to-comment", () => {
beforeEach(() => {
scrollToIndexSpy.mockClear();
});
it("scrolls to the highlighted comment after both issue and timeline finish loading", async () => {
renderIssueDetailWithHighlight("comment-2");
// Wait for the comment row to mount under the virtuoso mock.
// Wait for the comment row to mount. With initialItemCount in
// production, items[0..targetIdx] are force-mounted on first commit;
// the mock unconditionally inline-renders every item, so this just
// waits for the regular render pass.
await waitFor(() => {
expect(
document.querySelector('[data-comment-id="comment-2"]'),
document.getElementById("comment-comment-2"),
).not.toBeNull();
});
// The deep-link effect calls virtuosoRef.scrollToIndex with the
// target's index. comment-2 is items[1] in the flat timeline
// (items[0] = comment-1, items[1] = comment-2). The effect calls
// scrollToIndex twice (once on enter, once after the settle
// timeout); we only need to see at least one call land.
// The deep-link useLayoutEffect calls native scrollIntoView on the
// target node ({block: 'center'}).
await waitFor(() => {
expect(scrollToIndexSpy).toHaveBeenCalled();
expect(scrollIntoViewSpy).toHaveBeenCalled();
});
expect(scrollToIndexSpy).toHaveBeenCalledWith(
expect.objectContaining({ index: 1, align: "center" }),
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
expect.objectContaining({ block: "center" }),
);
});
@@ -628,9 +636,8 @@ describe("IssueDetail (shared)", () => {
// Reproduces the inbox-click race: timeline data is in the cache
// before the issue resolves. While loading is true, IssueDetail
// renders the loading skeleton (Virtuoso never mounts), so no
// scrollToIndex can fire. After the issue resolves, Virtuoso
// mounts, the bootstrapRef capture path or the warm-path effect
// fires scrollToIndex with the target index.
// scroll can fire. After the issue resolves, Virtuoso mounts and
// the useLayoutEffect dispatches the native scroll.
let resolveIssue: (value: Issue) => void = () => {};
const issuePromise = new Promise<Issue>((resolve) => {
resolveIssue = resolve;
@@ -640,20 +647,77 @@ describe("IssueDetail (shared)", () => {
renderIssueDetailWithHighlight("comment-2", "issue-1", { seedTimeline: true });
expect(
document.querySelector('[data-comment-id="comment-2"]'),
document.getElementById("comment-comment-2"),
).toBeNull();
expect(scrollToIndexSpy).not.toHaveBeenCalled();
expect(scrollIntoViewSpy).not.toHaveBeenCalled();
resolveIssue(mockIssue);
await waitFor(() => {
expect(
document.querySelector('[data-comment-id="comment-2"]'),
document.getElementById("comment-comment-2"),
).not.toBeNull();
});
await waitFor(() => {
expect(scrollToIndexSpy).toHaveBeenCalledWith(
expect.objectContaining({ index: 1, align: "center" }),
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
expect.objectContaining({ block: "center" }),
);
});
});
it("auto-expands a folded resolved thread when deep-link target is a reply inside it", async () => {
// Seed a timeline where comment-3 is resolved (so it renders as a
// resolved-bar by default) and has a reply, reply-1, whose id is the
// deep-link target. The reply is not in the flat items array — only
// the resolved-bar root is. The effect must detect this, expand the
// thread, then on re-run scroll to the reply's id="comment-reply-1" node.
const timelineWithResolvedThread: TimelineEntry[] = [
...mockTimeline,
{
type: "comment",
id: "comment-3",
actor_type: "member",
actor_id: "user-1",
content: "Resolved root",
parent_id: null,
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
comment_type: "comment",
resolved_at: "2026-01-19T00:00:00Z",
} as TimelineEntry,
{
type: "comment",
id: "reply-1",
actor_type: "member",
actor_id: "user-1",
content: "Reply inside resolved thread",
parent_id: "comment-3",
created_at: "2026-01-18T01:00:00Z",
updated_at: "2026-01-18T01:00:00Z",
comment_type: "comment",
} as TimelineEntry,
];
mockApiObj.listTimeline.mockResolvedValue(timelineWithResolvedThread);
const queryClient = createTestQueryClient();
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<IssueDetail issueId="issue-1" highlightCommentId="reply-1" />
</QueryClientProvider>
</I18nProvider>,
);
// After expansion, the reply must appear in the DOM (inside the now
// -unfolded CommentCard) and the deep-link effect must scroll to it.
await waitFor(() => {
expect(
document.getElementById("comment-reply-1"),
).not.toBeNull();
});
await waitFor(() => {
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
expect.objectContaining({ block: "center" }),
);
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from "react";
import { Virtuoso } from "react-virtuoso";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
@@ -401,7 +401,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// Virtuoso prop would never receive the element. Callback ref + state fixes
// that: setState triggers the re-render that hands Virtuoso the element.
const [scrollContainerEl, setScrollContainerEl] = useState<HTMLDivElement | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
// Per-session: which resolved threads the user has temporarily expanded.
@@ -670,83 +669,44 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const loading = issueLoading;
// First-paint deep-link bootstrap. Captured exactly once, on the first
// render where items[] is populated. Why a ref rather than a lazy
// useState initializer: IssueDetail mounts long before timeline data
// resolves (items.length is 0), so a lazy useState would freeze in
// the "no bootstrap" state forever. The ref follows React's documented
// "avoid recreating ref contents" idiom (the Video example in the
// useRef docs): write synchronously in render, gated by a one-shot
// flag so the value can't be overwritten on later renders.
// Deep-link landing. Semantically equivalent to navigating to
// `#comment-${id}`: find the element with that id, scrollIntoView it.
// When `highlightCommentId` is set the timeline below renders flat (no
// virtualization), so every comment id is in the DOM by the time this
// effect runs after commit.
//
// Passed to <Virtuoso initialTopMostItemIndex>: the only position
// anchor that runs *before* first paint, dodging the post-mount
// scrollToIndex race (Virtuoso #883). Subsequent deep-links inside
// the same mount (user clicks a second inbox notification on the
// same issue) go through scrollToIndex in the effect below — the
// bootstrap value is only consumed on cold start.
const bootstrapRef = useRef<{
resolved: boolean;
value?: { index: number; align: "center" };
}>({ resolved: false });
if (!bootstrapRef.current.resolved && items.length > 0) {
bootstrapRef.current = {
resolved: true,
value:
highlightCommentId && targetIdx >= 0
? { index: targetIdx, align: "center" }
: undefined,
};
}
const initialBootstrap = bootstrapRef.current.value;
// Deep-link landing. Virtualization makes "land precisely" not a
// single operation but a convergence: Virtuoso first uses estimated
// spacer heights to scroll to the target, mounts viewport items, the
// ResizeObserver fires real measurements, spacer heights update, and
// scrollTop is corrected. Markdown render and lowlight code highlight
// can reflow items again later in the same frame, triggering another
// round of correction. Trying to outsmart this with a single
// perfectly-timed scroll is what made the previous two attempts both
// complex and unreliable.
// For a reply inside a folded resolved thread, the reply is not in items
// (only the resolved-bar root is). Auto-expand the thread first; the
// effect re-runs once items re-flatten.
//
// This effect cooperates with Virtuoso's correction loop instead of
// fighting it: schedule three scrollToIndex calls — immediate, 120ms
// (after the first measurement pass), 500ms (after markdown/lowlight
// settle). Each call uses whatever spacer heights are current, so
// the convergence narrows on each pass. Visually this is a single
// instant scroll with at most a few pixels of late re-centering —
// not a re-jump, because each pass starts from the previous result.
//
// virtuosoRef.scrollToIndex (not native el.scrollIntoView) keeps
// Virtuoso's internal scrollTop model consistent (petyosi #1083).
// `scrollContainerEl` is in deps because the component early-returns a
// loading skeleton while the issue query is pending. The scroll-container
// ref populates only on the post-loading render, so it's the signal that
// the timeline (and the deep-link target id) has actually rendered.
useEffect(() => {
if (!highlightCommentId || items.length === 0 || targetIdx < 0) return;
if (!scrollContainerEl) return;
if (!highlightCommentId || items.length === 0) return;
if (didHighlightRef.current === highlightCommentId) return;
const rootId = replyToRoot.get(highlightCommentId);
if (
rootId &&
rootId !== highlightCommentId &&
items[targetIdx]?.kind === "resolved-bar"
) {
toggleResolvedExpand(rootId, true);
return;
}
const el = document.getElementById(`comment-${highlightCommentId}`);
if (!el) return;
didHighlightRef.current = highlightCommentId;
const land = () =>
virtuosoRef.current?.scrollToIndex({
index: targetIdx,
align: "center",
behavior: "auto",
});
land();
const t1 = window.setTimeout(land, 120);
const t2 = window.setTimeout(land, 500);
el.scrollIntoView({ block: "center" });
setHighlightedId(highlightCommentId);
const fade = window.setTimeout(() => setHighlightedId(null), 2500);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(fade);
};
}, [highlightCommentId, items.length, targetIdx, scrollContainerEl]);
return () => clearTimeout(fade);
}, [highlightCommentId, items, targetIdx, scrollContainerEl, replyToRoot, toggleResolvedExpand]);
// Cmd-F / Ctrl-F on a virtualized timeline only searches what's mounted in
// the viewport — off-screen comments are invisible to browser find-in-page.
@@ -993,6 +953,97 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
);
// Shared row renderer for both timeline render modes (flat / virtualized).
// The wrapper `id="comment-..."` is the deep-link target — equivalent to
// a native `<a href="#comment-...">` anchor.
const renderItem = (_i: number, item: TimelineItem): React.ReactElement => {
if (item.kind === "resolved-bar") {
return (
<div className="pb-3" id={`comment-${item.id}`}>
<ResolvedThreadBar
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
onExpand={() => toggleResolvedExpand(item.id, true)}
/>
</div>
);
}
if (item.kind === "comment") {
const isResolved = !!item.entry.resolved_at;
return (
<div className="pb-3" id={`comment-${item.id}`}>
<CommentCard
issueId={id}
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
onResolveToggle={handleResolveToggle}
onCollapseResolved={isResolved ? () => toggleResolvedExpand(item.id, false) : undefined}
highlightedCommentId={highlightedId}
/>
</div>
);
}
// activity-group
return (
<div className="pb-3 px-4 flex flex-col gap-3">
{item.entries.map((entry) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
);
};
const detailContent = (
<div className="flex h-full min-w-0 flex-1 flex-col">
<PageHeader className="gap-2 bg-background text-sm">
@@ -1106,16 +1157,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
</PageHeader>
{/* Content — scrollable. `overflow-anchor: none` disables the
browser's built-in scroll-anchoring so that late layout shifts
inside the virtualized timeline (Virtuoso resizing list items
above the viewport, images inside comments resolving their
natural size, etc.) don't silently nudge scrollTop and undo
the deep-link scroll we just performed. */}
<div
ref={setScrollContainerEl}
className="relative flex-1 overflow-y-auto"
style={{ overflowAnchor: "none" }}
>
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<TitleEditor
@@ -1378,137 +1422,55 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
miscomputes total-height on first paint. */}
{timelineLoading && timelineView.groups.length === 0 ? (
<TimelineSkeleton />
) : !scrollContainerEl ? (
// Show skeleton (not blank) while the callback ref populates,
// so the gap between IssueDetail mount and Virtuoso mount feels
// continuous with the loading state instead of flashing empty.
<TimelineSkeleton />
) : (
<div className="mt-4">
<Virtuoso
key={`${wsId}:${id}`}
ref={virtuosoRef}
customScrollParent={scrollContainerEl}
data={items}
increaseViewportBy={{ top: 800, bottom: 800 }}
computeItemKey={(_i, item) => `${item.kind}:${item.id}`}
skipAnimationFrameInResizeObserver
// First-paint anchor for inbox deep-link. Only ever
// populated on initial mount (see `initialBootstrap` /
// `bootstrapRef` above); subsequent re-renders pass the
// same value, so Virtuoso #458 (this prop acting as a
// persistent anchor that resets scrollTop on height
// changes) doesn't fire. Warm-path deep-links — user
// clicks a second inbox notification on the same issue
// — go through scrollToIndex in the effect above.
//
// Spread-on-defined: passing `initialTopMostItemIndex
// ={undefined}` triggers a runtime crash inside
// react-virtuoso ("Cannot read properties of undefined
// (reading 'index')") because the library accesses
// `.index` on the prop without a null guard. Omitting
// the prop entirely takes the library's default path.
{...(initialBootstrap && { initialTopMostItemIndex: initialBootstrap })}
// followOutput intentionally NOT set. Virtuoso treats it as
// a sticky "is at bottom" flag and resets scrollTop to
// maxScrollTop on every ResizeObserver / height-change tick
// — this is what was yanking the user back to scrollTop=299
// whenever they tried to scroll up after a deep-link
// landed on the last item. Issue-detail is document-shaped
// (not a chat), so auto-follow on new comments is not
// critical; users can scroll to bottom themselves.
itemContent={(_i, item) => {
if (item.kind === "resolved-bar") {
return (
// data-comment-id retained for any external code
// (tests, debugging, future deep-link variants)
// that wants to find a comment node directly.
<div className="pb-3" data-comment-id={item.id}>
<ResolvedThreadBar
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
onExpand={() => toggleResolvedExpand(item.id, true)}
/>
</div>
);
}
if (item.kind === "comment") {
const isResolved = !!item.entry.resolved_at;
return (
<div className="pb-3" data-comment-id={item.id}>
<CommentCard
issueId={id}
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
onResolveToggle={handleResolveToggle}
onCollapseResolved={isResolved ? () => toggleResolvedExpand(item.id, false) : undefined}
highlightedCommentId={highlightedId}
/>
</div>
);
}
// activity-group
return (
<div className="pb-3 px-4 flex flex-col gap-3">
{item.entries.map((entry) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
);
}}
/>
</div>
// Two render modes:
// - `highlightCommentId` set (came from inbox deep-link) →
// render flat. Every comment mounts, every height is real,
// the target id is in the DOM the instant the useEffect
// above runs `scrollIntoView`. No virtualization estimate
// errors, no spacer reflow drift. Pays cold-mount cost
// proportional to items.length (markdown + lowlight per
// comment), which is acceptable in the deep-link case —
// the user has explicit intent to land on a specific item.
// - otherwise → Virtuoso. Browsing mode, virtualization
// wins on first-paint perf for long timelines.
//
// The split is deliberate: virtualization and "land precisely
// on a target" have fundamentally opposed contracts (estimated
// heights vs real heights). Trying to satisfy both in one
// path is what produced the bug history this PR closes.
!highlightCommentId ? (
!scrollContainerEl ? (
// Skeleton while the callback ref populates so the gap
// between IssueDetail mount and Virtuoso mount doesn't
// flash empty.
<TimelineSkeleton />
) : (
<div className="mt-4">
<Virtuoso
key={`${wsId}:${id}`}
customScrollParent={scrollContainerEl}
data={items}
increaseViewportBy={{ top: 800, bottom: 800 }}
computeItemKey={(_i, item) => `${item.kind}:${item.id}`}
skipAnimationFrameInResizeObserver
// followOutput intentionally NOT set. Virtuoso treats
// it as a sticky "is at bottom" flag and resets
// scrollTop to maxScrollTop on every height-change
// tick — issue-detail is document-shaped, not chat.
itemContent={renderItem}
/>
</div>
)
) : (
<div className="mt-4">
{items.map((item, i) => (
<Fragment key={`${item.kind}:${item.id}`}>
{renderItem(i, item)}
</Fragment>
))}
</div>
)
)}
{/* Bottom comment input — no avatar, full width */}

View File

@@ -0,0 +1,76 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { useT } from "../../i18n";
// Reusable confirm step for the two issue-detail surfaces that terminate
// a single agent task — the sticky AgentLiveCard banner and the row
// action inside ExecutionLogSection. Task cancellation is irreversible
// and a misclick on a long-running run is costly, so both entry points
// route through this dialog instead of firing the cancel request on the
// first click.
//
// The dialog is fully controlled by the caller (which already owns the
// confirmCancel state alongside the in-flight cancelling state). When the
// caller signals it is currently terminating, the action button shows a
// disabled state so a second click cannot race the in-flight request.
interface TerminateTaskConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
// Running tasks may take a few seconds to fully halt on the daemon
// side. Queued tasks cancel immediately, so we omit the note to keep
// the copy honest.
showRunningNote?: boolean;
}
export function TerminateTaskConfirmDialog({
open,
onOpenChange,
onConfirm,
showRunningNote = false,
}: TerminateTaskConfirmDialogProps) {
const { t } = useT("issues");
if (!open) return null;
return (
<AlertDialog open onOpenChange={onOpenChange}>
<AlertDialogContent
// Stop clicks inside the dialog from bubbling to the row /
// banner underneath (the dialog can render inside a clickable
// ancestor — e.g. an ExecutionLogSection row).
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.terminate_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.terminate_dialog.body)}
{showRunningNote && t(($) => $.terminate_dialog.running_note)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.terminate_dialog.keep)}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
onOpenChange(false);
onConfirm();
}}
>
{t(($) => $.terminate_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -239,6 +239,13 @@
"status_failed": "Failed",
"status_cancelled": "Cancelled"
},
"terminate_dialog": {
"title": "Stop this task?",
"body": "The task will be cancelled and cannot be resumed.",
"running_note": " Running work may take a few seconds to fully halt.",
"keep": "Keep running",
"confirm": "Stop task"
},
"batch": {
"selected": "{{count}} selected",
"status": "Status",

View File

@@ -167,14 +167,9 @@
"connect_disabled_tooltip": "GitHub App is not configured on this server",
"not_configured": "GitHub integration is not configured for this deployment. Operators must set",
"not_configured_and": "and",
"loading": "Loading…",
"empty": "No GitHub accounts connected yet.",
"connected_at": "{{type}} · connected {{date}}",
"manage_hint": "Only admins and owners can manage integrations.",
"toast_not_configured": "GitHub integration is not configured for this deployment",
"toast_open_failed": "Failed to open GitHub install",
"toast_disconnected": "Disconnected GitHub account",
"toast_disconnect_failed": "Failed to disconnect"
"toast_open_failed": "Failed to open GitHub install"
},
"repositories": {
"section_title": "Repositories",

View File

@@ -234,6 +234,13 @@
"status_failed": "失败",
"status_cancelled": "已取消"
},
"terminate_dialog": {
"title": "确定停止该 task",
"body": "Task 会被取消且无法恢复。",
"running_note": " 进行中的 task 可能需要几秒才能完全停止。",
"keep": "保留运行",
"confirm": "停止 task"
},
"batch": {
"selected": "已选择 {{count}} 个",
"status": "状态",

View File

@@ -167,14 +167,9 @@
"connect_disabled_tooltip": "服务端未配置 GitHub App",
"not_configured": "当前部署没有配置 GitHub 集成。运维需要设置",
"not_configured_and": "和",
"loading": "加载中…",
"empty": "还没有连接 GitHub 账户。",
"connected_at": "{{type}} · {{date}} 连接",
"manage_hint": "只有管理员和所有者可以管理集成。",
"toast_not_configured": "当前部署未配置 GitHub 集成",
"toast_open_failed": "打开 GitHub 安装页失败",
"toast_disconnected": "已断开 GitHub 账户",
"toast_disconnect_failed": "断开连接失败"
"toast_open_failed": "打开 GitHub 安装页失败"
},
"repositories": {
"section_title": "代码仓库",

View File

@@ -464,7 +464,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
{t(($) => $.create_project.repos_heading)}
</div>
{workspaceRepos.length > 0 ? (
<div className="space-y-1">
<div className="space-y-1 max-h-48 overflow-y-auto">
{workspaceRepos.map((repo) => {
const checked = selectedRepos.includes(repo.url);
return (

View File

@@ -117,7 +117,7 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {
{t(($) => $.resources.popover_title)}
</div>
{workspace?.repos && workspace.repos.length > 0 && (
<div className="space-y-1">
<div className="space-y-1 max-h-48 overflow-y-auto">
{workspace.repos.map((repo) => {
const isAttached = attachedUrls.has(repo.url);
const isDisabled = isAttached || createResource.isPending;

View File

@@ -206,7 +206,7 @@ function PageHeaderBar({
<p className="ml-2 hidden text-xs text-muted-foreground md:block">
{t(($) => $.page.tagline)}{" "}
<a
href="https://multica.ai/docs/runtimes"
href="https://multica.ai/docs/daemon-runtimes"
target="_blank"
rel="noopener noreferrer"
className="underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground"

View File

@@ -1,15 +1,14 @@
"use client";
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { githubInstallationsOptions, githubKeys } from "@multica/core/github/queries";
import { githubInstallationsOptions } from "@multica/core/github/queries";
import { api } from "@multica/core/api";
import { useT } from "../../i18n";
@@ -27,21 +26,19 @@ export function IntegrationsTab() {
const { t } = useT("settings");
const wsId = useWorkspaceId();
const user = useAuthStore((s) => s.user);
const qc = useQueryClient();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const [connecting, setConnecting] = useState(false);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManage = currentMember?.role === "owner" || currentMember?.role === "admin";
// The list endpoint is admin-only; non-admins would see a 403 toast and
// an empty configured state. Gate the query on canManage so members get
// a clean read-only view.
const { data, isLoading } = useQuery({
// Only used to gate the Connect button + show a "not configured" hint;
// we no longer render the installation list here — admins manage existing
// installations on GitHub directly via the Connect flow.
const { data } = useQuery({
...githubInstallationsOptions(wsId),
enabled: !!wsId && canManage,
});
const installations = data?.installations ?? [];
const configured = data?.configured ?? false;
async function handleConnect() {
@@ -60,16 +57,6 @@ export function IntegrationsTab() {
}
}
async function handleDisconnect(installationDbId: string) {
try {
await api.deleteGitHubInstallation(wsId, installationDbId);
qc.invalidateQueries({ queryKey: githubKeys.installations(wsId) });
toast.success(t(($) => $.integrations.toast_disconnected));
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.integrations.toast_disconnect_failed));
}
}
return (
<div className="space-y-8">
<section className="space-y-4">
@@ -115,55 +102,6 @@ export function IntegrationsTab() {
</p>
)}
{canManage && configured && (
<div className="space-y-2">
{isLoading && <p className="text-xs text-muted-foreground">{t(($) => $.integrations.loading)}</p>}
{!isLoading && installations.length === 0 && (
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.empty)}
</p>
)}
{installations.map((inst) => (
<div
key={inst.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="flex items-center gap-2 min-w-0">
{inst.account_avatar_url && (
<img
src={inst.account_avatar_url}
alt=""
className="h-6 w-6 rounded-full shrink-0"
/>
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{inst.account_login}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.connected_at, {
type: inst.account_type,
date: new Date(inst.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
})}
</p>
</div>
</div>
{canManage && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => handleDisconnect(inst.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
)}
{!canManage && (
<p className="text-xs text-muted-foreground">
{t(($) => $.integrations.manage_hint)}

View File

@@ -84,8 +84,8 @@ func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
if payload.URL == "" {
return nil, errors.New("github_repo: url is required")
}
if u, err := url.Parse(payload.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("github_repo: url must be a valid http(s) URL")
if !isValidGitRepoURL(payload.URL) {
return nil, errors.New("github_repo: url must be a valid http(s) or ssh git URL")
}
payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint)
out, err := json.Marshal(payload)
@@ -95,6 +95,49 @@ func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
return out, nil
}
// isValidGitRepoURL accepts the three forms a user can paste from GitHub's
// "Code" menu: https://, ssh:// (with explicit scheme), and the scp-like
// shorthand `git@host:owner/repo.git`. The check is intentionally lax — we are
// guarding against pasted garbage like "not-a-url", not enforcing a strict
// grammar — because the actual fetch happens client-side via `git clone` and
// the user gets a clearer error from git than from us.
func isValidGitRepoURL(s string) bool {
if u, err := url.Parse(s); err == nil && u.Host != "" {
switch u.Scheme {
case "http", "https", "ssh", "git":
return true
}
}
// scp-like ssh shorthand: [user@]host:path with a non-empty host and path,
// and no spaces. Reject anything that looks like a URL with a scheme
// (those should go through url.Parse above).
if strings.Contains(s, " ") || strings.Contains(s, "://") {
return false
}
colon := strings.Index(s, ":")
if colon <= 0 || colon == len(s)-1 {
return false
}
// In scp-like ssh shorthand `[user@]host:path`, `@` is only meaningful
// as a user separator before the first ':'. If '@' appears at or after
// the colon it is not the user separator — reject as malformed rather
// than guess (and avoid a slice-bounds panic from blindly slicing).
at := strings.Index(s, "@")
if at >= colon {
return false
}
hostStart := 0
if at >= 0 {
hostStart = at + 1
}
host := s[hostStart:colon]
path := s[colon+1:]
if host == "" || path == "" {
return false
}
return true
}
// loadProjectForResource resolves the project, enforces workspace ownership,
// and returns its DB row. Used by all project_resource handlers.
func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) {

View File

@@ -135,6 +135,102 @@ func TestProjectResourceLifecycle(t *testing.T) {
}
}
// TestProjectResourceAcceptsSSHRepoURLs covers GitHub issue #2484: SSH and
// scp-like git URLs must be accepted alongside https URLs, because workspace
// repos configured with an SSH remote previously got rejected when attached
// to a project.
func TestProjectResourceAcceptsSSHRepoURLs(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "SSH repo URL acceptance",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
defer func() {
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
r = withURLParam(r, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), r)
}()
cases := []struct {
name string
url string
}{
{"scp-like", "git@github.com:multica-ai/multica.git"},
{"ssh-scheme", "ssh://git@github.com/multica-ai/multica.git"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": tc.url},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProjectResource(%s): expected 201, got %d: %s", tc.url, w.Code, w.Body.String())
}
var created ProjectResourceResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode: %v", err)
}
var ref struct {
URL string `json:"url"`
}
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
t.Fatalf("decode resource_ref: %v", err)
}
if ref.URL != tc.url {
t.Errorf("ref.url = %q, want %q", ref.URL, tc.url)
}
})
}
}
func TestIsValidGitRepoURL(t *testing.T) {
good := []string{
"https://github.com/multica-ai/multica",
"https://github.com/multica-ai/multica.git",
"http://github.example.com/x/y",
"ssh://git@github.com/multica-ai/multica.git",
"ssh://git@github.com:22/multica-ai/multica.git",
"git@github.com:multica-ai/multica.git",
"git@gitlab.example.com:group/sub/repo.git",
}
bad := []string{
"",
"not-a-url",
"github.com/multica-ai/multica", // no scheme, no scp-style colon
"https://", // empty host
"git@github.com", // missing :path
"git@:foo/bar", // missing host
"git@github.com:", // missing path
"ftp://example.com/repo", // unsupported scheme
"file:///tmp/repo", // unsupported scheme
"some random text with spaces",
"github.com:org/repo@branch", // '@' after ':' belongs to the path, not user
"foo:bar@baz", // '@' after ':' with no scheme
":foo/bar", // leading ':' with no host
}
for _, s := range good {
if !isValidGitRepoURL(s) {
t.Errorf("isValidGitRepoURL(%q) = false, want true", s)
}
}
for _, s := range bad {
if isValidGitRepoURL(s) {
t.Errorf("isValidGitRepoURL(%q) = true, want false", s)
}
}
}
func TestCreateProjectAttachesResources(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{