mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 14:44:30 +02:00
Compare commits
13 Commits
fix/github
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0ee819a6 | ||
|
|
65cc376f11 | ||
|
|
24a59098d6 | ||
|
|
0ef48797ae | ||
|
|
eca36fac84 | ||
|
|
e3e61c161c | ||
|
|
a0c64aaf65 | ||
|
|
2e4d6aa3a9 | ||
|
|
a02e58b488 | ||
|
|
61ca43835a | ||
|
|
f17acc21de | ||
|
|
01bcede2ad | ||
|
|
0e7fa21832 |
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](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)**
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](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) | 简体中文**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -141,6 +141,22 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
**`FRONTEND_ORIGIN` 不设就有两个静默失败**:(1)邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例;(2)WebSocket 连接的 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_*` 参数的行为含义
|
||||
|
||||
183
apps/docs/content/docs/github-integration.mdx
Normal file
183
apps/docs/content/docs/github-integration.mdx
Normal 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
|
||||
183
apps/docs/content/docs/github-integration.zh.mdx
Normal file
183
apps/docs/content/docs/github-integration.zh.mdx
Normal 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**——没 App,Multica 收不到 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 变量
|
||||
@@ -27,6 +27,8 @@
|
||||
"autopilots",
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"github-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"autopilots",
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"github-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }} />
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -234,6 +234,13 @@
|
||||
"status_failed": "失败",
|
||||
"status_cancelled": "已取消"
|
||||
},
|
||||
"terminate_dialog": {
|
||||
"title": "确定停止该 task?",
|
||||
"body": "Task 会被取消且无法恢复。",
|
||||
"running_note": " 进行中的 task 可能需要几秒才能完全停止。",
|
||||
"keep": "保留运行",
|
||||
"confirm": "停止 task"
|
||||
},
|
||||
"batch": {
|
||||
"selected": "已选择 {{count}} 个",
|
||||
"status": "状态",
|
||||
|
||||
@@ -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": "代码仓库",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user