mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 13:18:56 +02:00
Compare commits
66 Commits
agent/lamb
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc8dcc053 | ||
|
|
473ec17c65 | ||
|
|
fc6405e4be | ||
|
|
7b610a4013 | ||
|
|
978e81a268 | ||
|
|
c9c8230271 | ||
|
|
b84543e634 | ||
|
|
6c651f4be5 | ||
|
|
b5924ffa99 | ||
|
|
30640436c4 | ||
|
|
eb355dbc9c | ||
|
|
f34ed091e7 | ||
|
|
d9a6b8c8ed | ||
|
|
27e58d91af | ||
|
|
8eb1caa72b | ||
|
|
35b379d688 | ||
|
|
392a8d7c8c | ||
|
|
fd744c331e | ||
|
|
a98f165458 | ||
|
|
097630c733 | ||
|
|
c3cca50f27 | ||
|
|
36ba23b3cd | ||
|
|
5df444ba00 | ||
|
|
e6a1ff4354 | ||
|
|
7cc4e63e0e | ||
|
|
36db325d50 | ||
|
|
d751373368 | ||
|
|
09764c5f51 | ||
|
|
565afed447 | ||
|
|
222f60d2dd | ||
|
|
e8c2a8eff9 | ||
|
|
7f0cb106bd | ||
|
|
c7fda85a3e | ||
|
|
b8b4731602 | ||
|
|
fe975fb2bb | ||
|
|
fc0ef0fcd8 | ||
|
|
62b7c0cfa2 | ||
|
|
606930725a | ||
|
|
ed9aef8f39 | ||
|
|
856a254252 | ||
|
|
682dc20ba9 | ||
|
|
17418f37b2 | ||
|
|
eba2e7eacf | ||
|
|
fdba410f11 | ||
|
|
a80d61f8e1 | ||
|
|
ff616de82b | ||
|
|
f353e8db59 | ||
|
|
575bbd7f60 | ||
|
|
cd1b1155c1 | ||
|
|
68da1efd74 | ||
|
|
bea739cba5 | ||
|
|
b88c7f1afa | ||
|
|
f05f3face3 | ||
|
|
bf713d3ad5 | ||
|
|
ac06e7f4a3 | ||
|
|
0659865645 | ||
|
|
ab505fd39c | ||
|
|
7b19ad7ccc | ||
|
|
6b0c9bba9e | ||
|
|
60e66f32b3 | ||
|
|
eb35bc5dc9 | ||
|
|
99fdf39c50 | ||
|
|
e4b2053d90 | ||
|
|
1264a1fa67 | ||
|
|
c4a24c1a86 | ||
|
|
479d69305d |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.26.1"
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Build
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ apps/web/test-results/
|
||||
|
||||
# local settings
|
||||
.claude/
|
||||
.tool-versions
|
||||
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
@@ -149,7 +149,7 @@ make db-down # Stop shared PostgreSQL
|
||||
|
||||
### CI Requirements
|
||||
|
||||
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
|
||||
### Worktree Support
|
||||
|
||||
|
||||
@@ -289,6 +289,23 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
### Execution History
|
||||
|
||||
```bash
|
||||
# List all execution runs for an issue
|
||||
multica issue runs <issue-id>
|
||||
multica issue runs <issue-id> --output json
|
||||
|
||||
# View messages for a specific execution run
|
||||
multica issue run-messages <task-id>
|
||||
multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
|
||||
118
README.md
118
README.md
@@ -1,28 +1,57 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/banner.jpg" alt="Multica — humans and agents, side by side" width="100%">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
|
||||
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
|
||||
</picture>
|
||||
|
||||
# Multica
|
||||
|
||||
AI-native project management — like Linear, but with AI agents as first-class team members.
|
||||
**Your next 10 hires won't be human.**
|
||||
|
||||
Multica lets you manage tasks and collaborate with AI agents the same way you work with human teammates. Agents can be assigned issues, post comments, update statuses, and execute work autonomously on your local machine.
|
||||
Open-source platform that turns coding agents into real teammates.<br/>
|
||||
Assign tasks, track progress, compound skills — manage your human + agent workforce in one place.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
</div>
|
||||
|
||||
## What is Multica?
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Works with **Claude Code** and **Codex**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **AI agents as teammates** — assign issues to agents, mention them in comments, and let them do the work
|
||||
- **Local agent runtime** — agents run on your machine using Claude Code or Codex, with full access to your codebase
|
||||
- **Real-time collaboration** — WebSocket-powered live updates across the board
|
||||
- **Multi-workspace** — organize work across teams with workspace-level isolation
|
||||
- **Familiar UX** — if you've used Linear, you'll feel right at home
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Use Multica Cloud
|
||||
### Multica Cloud
|
||||
|
||||
The fastest way to get started: [multica.ai](https://multica.ai)
|
||||
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
|
||||
|
||||
### Self-Host
|
||||
|
||||
Run Multica on your own infrastructure. See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
|
||||
|
||||
Quick start with Docker:
|
||||
### Self-Host with Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
@@ -30,14 +59,13 @@ cd multica
|
||||
cp .env.example .env
|
||||
# Edit .env — at minimum, change JWT_SECRET
|
||||
|
||||
# Start PostgreSQL
|
||||
docker compose up -d
|
||||
|
||||
# Build and run the backend
|
||||
cd server && go run ./cmd/migrate up && cd ..
|
||||
make start
|
||||
docker compose up -d # Start PostgreSQL
|
||||
cd server && go run ./cmd/migrate up && cd .. # Run migrations
|
||||
make start # Start the app
|
||||
```
|
||||
|
||||
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
@@ -56,6 +84,35 @@ The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. W
|
||||
|
||||
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
|
||||
|
||||
### 1. Log in and start the daemon
|
||||
|
||||
```bash
|
||||
multica login # Authenticate with your Multica account
|
||||
multica daemon start # Start the local agent runtime
|
||||
```
|
||||
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
|
||||
|
||||
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
|
||||
|
||||
That's it! Your agent is now part of the team. 🎉
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -70,23 +127,18 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
- **Frontend**: Next.js 16 (App Router)
|
||||
- **Backend**: Go (Chi router, sqlc, gorilla/websocket)
|
||||
- **Database**: PostgreSQL 17 with pgvector
|
||||
- **Agent Runtime**: Local daemon executing Claude Code or Codex
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code or Codex |
|
||||
|
||||
## Development
|
||||
|
||||
For contributors working on the Multica codebase, see the [Contributing Guide](CONTRIBUTING.md).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v20+)
|
||||
- [pnpm](https://pnpm.io/) (v10.28+)
|
||||
- [Go](https://go.dev/) (v1.26+)
|
||||
- [Docker](https://www.docker.com/)
|
||||
|
||||
### Quick Start
|
||||
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -99,4 +151,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktr
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
154
README.zh-CN.md
Normal file
154
README.zh-CN.md
Normal file
@@ -0,0 +1,154 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/banner.jpg" alt="Multica — 人类与 AI,并肩前行" width="100%">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
|
||||
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
|
||||
</picture>
|
||||
|
||||
# Multica
|
||||
|
||||
**你的下一批员工,不是人类。**
|
||||
|
||||
开源平台,将编码 Agent 变成真正的队友。<br/>
|
||||
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
</div>
|
||||
|
||||
## Multica 是什么?
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code** 和 **Codex**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### Multica 云服务
|
||||
|
||||
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
|
||||
|
||||
### Docker 自部署
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
# 编辑 .env — 至少修改 JWT_SECRET
|
||||
|
||||
docker compose up -d # 启动 PostgreSQL
|
||||
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
|
||||
make start # 启动应用
|
||||
```
|
||||
|
||||
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
|
||||
|
||||
## CLI
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# 认证并启动
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
daemon 会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`)。当 Agent 被分配任务时,daemon 会创建隔离环境、运行 Agent、并将结果回传。
|
||||
|
||||
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
|
||||
|
||||
## 快速上手
|
||||
|
||||
安装好 CLI(或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent:
|
||||
|
||||
### 1. 登录并启动 daemon
|
||||
|
||||
```bash
|
||||
multica login # 使用你的 Multica 账号认证
|
||||
multica daemon start # 启动本地 Agent 运行时
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
在 Multica Web 端打开你的工作区,进入 **设置 → 运行时(Runtimes)**,你应该能看到你的机器已作为一个活跃的 **Runtime** 出现在列表中。
|
||||
|
||||
> **什么是 Runtime(运行时)?** Runtime 是可以执行 Agent 任务的计算环境。它可以是你的本地机器(通过 daemon 连接),也可以是云端实例。每个 Runtime 会上报可用的 Agent CLI,Multica 据此决定将任务路由到哪里执行。
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code 或 Codex),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
在看板上创建一个 Issue(或通过 `multica issue create` 命令创建),然后将其分配给你的新 Agent。Agent 会自动接手任务、在你的 Runtime 上执行、并实时汇报进度——就像一个真正的队友一样。
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ Next.js │────>│ Go 后端 │────>│ PostgreSQL │
|
||||
│ 前端 │<────│ (Chi + WS) │<────│ (pgvector) │
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (运行在你的机器上)
|
||||
│ Claude/Codex │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
|------|--------|
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code 或 Codex |
|
||||
|
||||
## 开发
|
||||
|
||||
参与 Multica 代码贡献,请参阅 [贡献指南](CONTRIBUTING.md)。
|
||||
|
||||
**环境要求:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
make setup
|
||||
make start
|
||||
```
|
||||
|
||||
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0.996",
|
||||
"green": "0.388",
|
||||
"red": "0.384"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,29 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Agent: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let workspaceId: String
|
||||
let name: String
|
||||
let description: String
|
||||
let instructions: String?
|
||||
let avatarURL: String?
|
||||
let status: AgentStatus
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, description, instructions, status
|
||||
case workspaceId = "workspace_id"
|
||||
case avatarURL = "avatar_url"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentStatus: String, Codable, Sendable {
|
||||
case idle
|
||||
case working
|
||||
case blocked
|
||||
case error
|
||||
case offline
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct AgentTask: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let agentId: String
|
||||
let runtimeId: String?
|
||||
let issueId: String
|
||||
let status: TaskStatus
|
||||
let priority: Int?
|
||||
let dispatchedAt: String?
|
||||
let startedAt: String?
|
||||
let completedAt: String?
|
||||
let error: String?
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, status, priority, error
|
||||
case agentId = "agent_id"
|
||||
case runtimeId = "runtime_id"
|
||||
case issueId = "issue_id"
|
||||
case dispatchedAt = "dispatched_at"
|
||||
case startedAt = "started_at"
|
||||
case completedAt = "completed_at"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
enum TaskStatus: String, Codable, Sendable {
|
||||
case queued
|
||||
case dispatched
|
||||
case running
|
||||
case completed
|
||||
case failed
|
||||
case cancelled
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .queued: "Queued"
|
||||
case .dispatched: "Dispatched"
|
||||
case .running: "Running"
|
||||
case .completed: "Completed"
|
||||
case .failed: "Failed"
|
||||
case .cancelled: "Cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .queued: "clock"
|
||||
case .dispatched: "arrow.right.circle"
|
||||
case .running: "play.circle.fill"
|
||||
case .completed: "checkmark.circle.fill"
|
||||
case .failed: "xmark.circle.fill"
|
||||
case .cancelled: "minus.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
self == .queued || self == .dispatched || self == .running
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Comment: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let issueId: String
|
||||
let authorType: String
|
||||
let authorId: String
|
||||
let content: String
|
||||
let type: String
|
||||
let parentId: String?
|
||||
let attachments: [Attachment]?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
// Joined fields from server
|
||||
let authorName: String?
|
||||
let authorAvatarURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, content, type, attachments
|
||||
case issueId = "issue_id"
|
||||
case authorType = "author_type"
|
||||
case authorId = "author_id"
|
||||
case parentId = "parent_id"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case authorName = "author_name"
|
||||
case authorAvatarURL = "author_avatar_url"
|
||||
}
|
||||
|
||||
var isFromAgent: Bool {
|
||||
authorType == "agent"
|
||||
}
|
||||
}
|
||||
|
||||
struct Attachment: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let filename: String
|
||||
let contentType: String?
|
||||
let url: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename, url
|
||||
case contentType = "content_type"
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum IssueStatus: String, Codable, CaseIterable, Sendable {
|
||||
case backlog
|
||||
case todo
|
||||
case inProgress = "in_progress"
|
||||
case inReview = "in_review"
|
||||
case done
|
||||
case blocked
|
||||
case cancelled
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .backlog: "Backlog"
|
||||
case .todo: "Todo"
|
||||
case .inProgress: "In Progress"
|
||||
case .inReview: "In Review"
|
||||
case .done: "Done"
|
||||
case .blocked: "Blocked"
|
||||
case .cancelled: "Cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .backlog: "circle.dashed"
|
||||
case .todo: "circle"
|
||||
case .inProgress: "circle.lefthalf.filled"
|
||||
case .inReview: "eye.circle"
|
||||
case .done: "checkmark.circle.fill"
|
||||
case .blocked: "xmark.circle"
|
||||
case .cancelled: "minus.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .backlog: "gray"
|
||||
case .todo: "gray"
|
||||
case .inProgress: "yellow"
|
||||
case .inReview: "blue"
|
||||
case .done: "green"
|
||||
case .blocked: "red"
|
||||
case .cancelled: "gray"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum IssuePriority: String, Codable, CaseIterable, Sendable {
|
||||
case urgent
|
||||
case high
|
||||
case medium
|
||||
case low
|
||||
case none
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .urgent: "Urgent"
|
||||
case .high: "High"
|
||||
case .medium: "Medium"
|
||||
case .low: "Low"
|
||||
case .none: "None"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .urgent: "exclamationmark.3"
|
||||
case .high: "exclamationmark.2"
|
||||
case .medium: "exclamationmark"
|
||||
case .low: "minus"
|
||||
case .none: "minus"
|
||||
}
|
||||
}
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .urgent: 0
|
||||
case .high: 1
|
||||
case .medium: 2
|
||||
case .low: 3
|
||||
case .none: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Issue: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let workspaceId: String
|
||||
let number: Int
|
||||
let identifier: String
|
||||
let title: String
|
||||
let description: String?
|
||||
let status: IssueStatus
|
||||
let priority: IssuePriority
|
||||
let assigneeType: String?
|
||||
let assigneeId: String?
|
||||
let creatorType: String
|
||||
let creatorId: String
|
||||
let parentIssueId: String?
|
||||
let position: Int
|
||||
let dueDate: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, number, identifier, title, description, status, priority, position
|
||||
case workspaceId = "workspace_id"
|
||||
case assigneeType = "assignee_type"
|
||||
case assigneeId = "assignee_id"
|
||||
case creatorType = "creator_type"
|
||||
case creatorId = "creator_id"
|
||||
case parentIssueId = "parent_issue_id"
|
||||
case dueDate = "due_date"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
var isAssignedToAgent: Bool {
|
||||
assigneeType == "agent"
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateIssueRequest: Codable, Sendable {
|
||||
let title: String
|
||||
let description: String?
|
||||
let status: String?
|
||||
let priority: String?
|
||||
let assigneeType: String?
|
||||
let assigneeId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, description, status, priority
|
||||
case assigneeType = "assignee_type"
|
||||
case assigneeId = "assignee_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateIssueRequest: Codable, Sendable {
|
||||
var title: String?
|
||||
var description: String?
|
||||
var status: String?
|
||||
var priority: String?
|
||||
var assigneeType: String?
|
||||
var assigneeId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, description, status, priority
|
||||
case assigneeType = "assignee_type"
|
||||
case assigneeId = "assignee_id"
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Member: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let userId: String
|
||||
let workspaceId: String
|
||||
let role: String
|
||||
let user: User?
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, role, user
|
||||
case userId = "user_id"
|
||||
case workspaceId = "workspace_id"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
user?.name ?? "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified type for displaying assignees (either member or agent)
|
||||
enum Assignee: Identifiable, Hashable, Sendable {
|
||||
case member(Member)
|
||||
case agent(Agent)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .member(let m): m.id
|
||||
case .agent(let a): a.id
|
||||
}
|
||||
}
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .member(let m): m.displayName
|
||||
case .agent(let a): a.name
|
||||
}
|
||||
}
|
||||
|
||||
var typeName: String {
|
||||
switch self {
|
||||
case .member: "member"
|
||||
case .agent: "agent"
|
||||
}
|
||||
}
|
||||
|
||||
var entityId: String {
|
||||
switch self {
|
||||
case .member(let m): m.userId
|
||||
case .agent(let a): a.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct TaskMessage: Codable, Identifiable, Sendable {
|
||||
let taskId: String
|
||||
let issueId: String?
|
||||
let seq: Int
|
||||
let type: MessageType
|
||||
let tool: String?
|
||||
let content: String?
|
||||
let input: [String: AnyCodable]?
|
||||
let output: String?
|
||||
|
||||
var id: String { "\(taskId)-\(seq)" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case seq, type, tool, content, input, output
|
||||
case taskId = "task_id"
|
||||
case issueId = "issue_id"
|
||||
}
|
||||
}
|
||||
|
||||
enum MessageType: String, Codable, Sendable {
|
||||
case text
|
||||
case thinking
|
||||
case toolUse = "tool_use"
|
||||
case toolResult = "tool_result"
|
||||
case error
|
||||
}
|
||||
|
||||
// Simple wrapper for heterogeneous JSON values
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
value = double
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
value = dict.mapValues(\.value)
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
value = array.map(\.value)
|
||||
} else {
|
||||
value = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
if let string = value as? String {
|
||||
try container.encode(string)
|
||||
} else if let int = value as? Int {
|
||||
try container.encode(int)
|
||||
} else if let double = value as? Double {
|
||||
try container.encode(double)
|
||||
} else if let bool = value as? Bool {
|
||||
try container.encode(bool)
|
||||
} else {
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct User: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let email: String
|
||||
let avatarURL: String?
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, email
|
||||
case avatarURL = "avatar_url"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthResponse: Codable, Sendable {
|
||||
let token: String
|
||||
let user: User
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Workspace: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let slug: String
|
||||
let description: String?
|
||||
let issuePrefix: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, slug, description
|
||||
case issuePrefix = "issue_prefix"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceRepo: Codable, Sendable {
|
||||
let url: String
|
||||
let description: String?
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MulticaApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case unauthorized
|
||||
case serverError(String)
|
||||
case networkError(Error)
|
||||
case decodingError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: "Invalid URL"
|
||||
case .unauthorized: "Session expired. Please log in again."
|
||||
case .serverError(let msg): msg
|
||||
case .networkError(let err): err.localizedDescription
|
||||
case .decodingError(let err): "Failed to parse response: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class APIClient: Sendable {
|
||||
static let shared = APIClient()
|
||||
|
||||
// Configure these for your server
|
||||
#if DEBUG
|
||||
let baseURL = "http://localhost:8080"
|
||||
#else
|
||||
let baseURL = "https://api.multica.ai"
|
||||
#endif
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 30
|
||||
session = URLSession(configuration: config)
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
var token: String? {
|
||||
get { KeychainHelper.read(key: "auth_token") }
|
||||
set {
|
||||
if let newValue {
|
||||
KeychainHelper.save(key: "auth_token", value: newValue)
|
||||
} else {
|
||||
KeychainHelper.delete(key: "auth_token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var workspaceId: String? {
|
||||
get { UserDefaults.standard.string(forKey: "workspace_id") }
|
||||
set { UserDefaults.standard.set(newValue, forKey: "workspace_id") }
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
func sendCode(email: String) async throws {
|
||||
let body = ["email": email]
|
||||
let _: EmptyResponse = try await post("/auth/send-code", body: body, authenticated: false)
|
||||
}
|
||||
|
||||
func verifyCode(email: String, code: String) async throws -> AuthResponse {
|
||||
let body = ["email": email, "code": code]
|
||||
return try await post("/auth/verify-code", body: body, authenticated: false)
|
||||
}
|
||||
|
||||
// MARK: - Workspaces
|
||||
|
||||
func listWorkspaces() async throws -> [Workspace] {
|
||||
try await get("/api/workspaces")
|
||||
}
|
||||
|
||||
func getWorkspace(_ id: String) async throws -> Workspace {
|
||||
try await get("/api/workspaces/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Issues
|
||||
|
||||
func listIssues(
|
||||
status: String? = nil,
|
||||
priority: String? = nil,
|
||||
assigneeId: String? = nil,
|
||||
limit: Int = 200,
|
||||
offset: Int = 0
|
||||
) async throws -> [Issue] {
|
||||
var params: [(String, String)] = [
|
||||
("limit", "\(limit)"),
|
||||
("offset", "\(offset)"),
|
||||
]
|
||||
if let status { params.append(("status", status)) }
|
||||
if let priority { params.append(("priority", priority)) }
|
||||
if let assigneeId { params.append(("assignee_id", assigneeId)) }
|
||||
return try await get("/api/issues", queryItems: params)
|
||||
}
|
||||
|
||||
func getIssue(_ id: String) async throws -> Issue {
|
||||
try await get("/api/issues/\(id)")
|
||||
}
|
||||
|
||||
func createIssue(_ req: CreateIssueRequest) async throws -> Issue {
|
||||
try await post("/api/issues", body: req)
|
||||
}
|
||||
|
||||
func updateIssue(_ id: String, _ req: UpdateIssueRequest) async throws -> Issue {
|
||||
try await put("/api/issues/\(id)", body: req)
|
||||
}
|
||||
|
||||
func deleteIssue(_ id: String) async throws {
|
||||
let _: EmptyResponse = try await request("DELETE", path: "/api/issues/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
func listComments(issueId: String) async throws -> [Comment] {
|
||||
try await get("/api/issues/\(issueId)/comments")
|
||||
}
|
||||
|
||||
func createComment(issueId: String, content: String, parentId: String? = nil) async throws -> Comment {
|
||||
var body: [String: String] = ["content": content]
|
||||
if let parentId { body["parent_id"] = parentId }
|
||||
return try await post("/api/issues/\(issueId)/comments", body: body)
|
||||
}
|
||||
|
||||
// MARK: - Members & Agents
|
||||
|
||||
func listMembers(workspaceId: String) async throws -> [Member] {
|
||||
try await get("/api/workspaces/\(workspaceId)/members")
|
||||
}
|
||||
|
||||
func listAgents() async throws -> [Agent] {
|
||||
try await get("/api/agents")
|
||||
}
|
||||
|
||||
// MARK: - Tasks
|
||||
|
||||
func getActiveTask(issueId: String) async throws -> AgentTask? {
|
||||
do {
|
||||
return try await get("/api/issues/\(issueId)/active-task")
|
||||
} catch APIError.serverError {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func listTaskRuns(issueId: String) async throws -> [AgentTask] {
|
||||
try await get("/api/issues/\(issueId)/task-runs")
|
||||
}
|
||||
|
||||
func listTaskMessages(taskId: String) async throws -> [TaskMessage] {
|
||||
try await get("/api/tasks/\(taskId)/messages")
|
||||
}
|
||||
|
||||
func cancelTask(issueId: String, taskId: String) async throws {
|
||||
let _: EmptyResponse = try await post("/api/issues/\(issueId)/tasks/\(taskId)/cancel", body: EmptyBody())
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
func listTimeline(issueId: String) async throws -> [TimelineEntry] {
|
||||
try await get("/api/issues/\(issueId)/timeline")
|
||||
}
|
||||
|
||||
// MARK: - Networking Helpers
|
||||
|
||||
private func get<T: Decodable>(_ path: String, queryItems: [(String, String)] = []) async throws -> T {
|
||||
try await request("GET", path: path, queryItems: queryItems)
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B, authenticated: Bool = true) async throws -> T {
|
||||
try await request("POST", path: path, body: body, authenticated: authenticated)
|
||||
}
|
||||
|
||||
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
try await request("PUT", path: path, body: body)
|
||||
}
|
||||
|
||||
private func request<T: Decodable>(
|
||||
_ method: String,
|
||||
path: String,
|
||||
queryItems: [(String, String)] = [],
|
||||
body: (any Encodable)? = nil,
|
||||
authenticated: Bool = true
|
||||
) async throws -> T {
|
||||
guard var components = URLComponents(string: baseURL + path) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = queryItems.map { URLQueryItem(name: $0.0, value: $0.1) }
|
||||
}
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
|
||||
if authenticated {
|
||||
guard let token else { throw APIError.unauthorized }
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
if let workspaceId, authenticated {
|
||||
request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-ID")
|
||||
}
|
||||
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
|
||||
let (data, response) : (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.serverError("Invalid response")
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
if httpResponse.statusCode >= 400 {
|
||||
if let errorBody = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
||||
throw APIError.serverError(errorBody.error)
|
||||
}
|
||||
throw APIError.serverError("Server error (\(httpResponse.statusCode))")
|
||||
}
|
||||
|
||||
// Handle empty responses
|
||||
if T.self == EmptyResponse.self {
|
||||
return EmptyResponse() as! T
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
struct EmptyBody: Encodable {}
|
||||
struct ErrorResponse: Decodable {
|
||||
let error: String
|
||||
}
|
||||
|
||||
struct TimelineEntry: Codable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let issueId: String?
|
||||
let actorType: String?
|
||||
let actorId: String?
|
||||
let action: String?
|
||||
let field: String?
|
||||
let oldValue: String?
|
||||
let newValue: String?
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, action, field
|
||||
case issueId = "issue_id"
|
||||
case actorType = "actor_type"
|
||||
case actorId = "actor_id"
|
||||
case oldValue = "old_value"
|
||||
case newValue = "new_value"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainHelper {
|
||||
private static let service = "ai.multica.app"
|
||||
|
||||
static func save(key: String, value: String) {
|
||||
guard let data = value.data(using: .utf8) else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var newItem = query
|
||||
newItem[kSecValueData as String] = data
|
||||
SecItemAdd(newItem as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func read(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func delete(key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct WSEvent: @unchecked Sendable {
|
||||
let type: String
|
||||
let payload: [String: Any]
|
||||
let actorId: String?
|
||||
|
||||
var prefix: String {
|
||||
String(type.prefix(while: { $0 != ":" }))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WebSocketClient: ObservableObject {
|
||||
static let shared = WebSocketClient()
|
||||
|
||||
@Published var isConnected = false
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var handlers: [(String, @Sendable (WSEvent) -> Void)] = []
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private let session = URLSession(configuration: .default)
|
||||
|
||||
private init() {}
|
||||
|
||||
func connect() {
|
||||
guard let token = APIClient.shared.token,
|
||||
let workspaceId = APIClient.shared.workspaceId else { return }
|
||||
|
||||
let wsScheme: String
|
||||
#if DEBUG
|
||||
wsScheme = "ws"
|
||||
#else
|
||||
wsScheme = "wss"
|
||||
#endif
|
||||
|
||||
let baseHost = APIClient.shared.baseURL
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
|
||||
guard let url = URL(string: "\(wsScheme)://\(baseHost)/ws?token=\(token)&workspace_id=\(workspaceId)") else {
|
||||
return
|
||||
}
|
||||
|
||||
webSocketTask = session.webSocketTask(with: url)
|
||||
webSocketTask?.resume()
|
||||
isConnected = true
|
||||
receiveMessage()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
isConnected = false
|
||||
handlers.removeAll()
|
||||
}
|
||||
|
||||
func on(_ eventType: String, handler: @escaping @Sendable (WSEvent) -> Void) {
|
||||
handlers.append((eventType, handler))
|
||||
}
|
||||
|
||||
func onPrefix(_ prefix: String, handler: @escaping @Sendable (WSEvent) -> Void) {
|
||||
handlers.append(("prefix:\(prefix)", handler))
|
||||
}
|
||||
|
||||
private func receiveMessage() {
|
||||
webSocketTask?.receive { [weak self] result in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
switch message {
|
||||
case .string(let text):
|
||||
self.handleMessage(text)
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
self.handleMessage(text)
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
self.receiveMessage()
|
||||
case .failure:
|
||||
self.isConnected = false
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ text: String) {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = json["type"] as? String else { return }
|
||||
|
||||
let payload = json["payload"] as? [String: Any] ?? [:]
|
||||
let actorId = json["actor_id"] as? String
|
||||
let event = WSEvent(type: type, payload: payload, actorId: actorId)
|
||||
|
||||
for (pattern, handler) in handlers {
|
||||
if pattern == type {
|
||||
handler(event)
|
||||
} else if pattern.hasPrefix("prefix:") {
|
||||
let prefix = String(pattern.dropFirst(7))
|
||||
if event.prefix == prefix {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard !Task.isCancelled else { return }
|
||||
self.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AuthViewModel {
|
||||
var email = ""
|
||||
var code = ""
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var codeSent = false
|
||||
var isAuthenticated = false
|
||||
var user: User?
|
||||
|
||||
init() {
|
||||
// Check for existing token
|
||||
if APIClient.shared.token != nil {
|
||||
isAuthenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
func sendCode() async {
|
||||
guard !email.isEmpty else {
|
||||
error = "Please enter your email"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
try await APIClient.shared.sendCode(email: email)
|
||||
codeSent = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func verifyCode() async {
|
||||
guard !code.isEmpty else {
|
||||
error = "Please enter the verification code"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.verifyCode(email: email, code: code)
|
||||
APIClient.shared.token = response.token
|
||||
user = response.user
|
||||
isAuthenticated = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logout() {
|
||||
APIClient.shared.token = nil
|
||||
APIClient.shared.workspaceId = nil
|
||||
WebSocketClient.shared.disconnect()
|
||||
isAuthenticated = false
|
||||
user = nil
|
||||
email = ""
|
||||
code = ""
|
||||
codeSent = false
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IssueDetailViewModel {
|
||||
var issue: Issue
|
||||
var comments: [Comment] = []
|
||||
var taskRuns: [AgentTask] = []
|
||||
var activeTask: AgentTask?
|
||||
var taskMessages: [TaskMessage] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
init(issue: Issue) {
|
||||
self.issue = issue
|
||||
}
|
||||
|
||||
func loadAll() async {
|
||||
isLoading = true
|
||||
do {
|
||||
async let commentsResult = APIClient.shared.listComments(issueId: issue.id)
|
||||
async let taskRunsResult = APIClient.shared.listTaskRuns(issueId: issue.id)
|
||||
async let activeTaskResult = APIClient.shared.getActiveTask(issueId: issue.id)
|
||||
|
||||
comments = try await commentsResult
|
||||
taskRuns = try await taskRunsResult
|
||||
activeTask = try await activeTaskResult
|
||||
|
||||
// Load messages for active task
|
||||
if let task = activeTask {
|
||||
taskMessages = try await APIClient.shared.listTaskMessages(taskId: task.id)
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func updateStatus(_ status: IssueStatus) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(status: status.rawValue))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updatePriority(_ priority: IssuePriority) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(priority: priority.rawValue))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateAssignee(type: String?, id: String?) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(
|
||||
assigneeType: type ?? "",
|
||||
assigneeId: id ?? ""
|
||||
))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateTitle(_ title: String) async {
|
||||
do {
|
||||
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(title: title))
|
||||
issue = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func addComment(_ content: String) async {
|
||||
do {
|
||||
let comment = try await APIClient.shared.createComment(issueId: issue.id, content: content)
|
||||
comments.append(comment)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func loadTaskMessages(taskId: String) async {
|
||||
do {
|
||||
taskMessages = try await APIClient.shared.listTaskMessages(taskId: taskId)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func setupRealtimeUpdates() {
|
||||
let issueId = issue.id
|
||||
WebSocketClient.shared.on("task:message") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self,
|
||||
let payload = event.payload["issue_id"] as? String,
|
||||
payload == issueId else { return }
|
||||
// Reload messages for active task
|
||||
if let task = self.activeTask {
|
||||
await self.loadTaskMessages(taskId: task.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WebSocketClient.shared.onPrefix("task") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
await self.loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
WebSocketClient.shared.onPrefix("comment") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.comments = (try? await APIClient.shared.listComments(issueId: issueId)) ?? self.comments
|
||||
}
|
||||
}
|
||||
|
||||
WebSocketClient.shared.on("issue:updated") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let updated = try? await APIClient.shared.getIssue(issueId) {
|
||||
self.issue = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IssueListViewModel {
|
||||
var issues: [Issue] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var statusFilter: IssueStatus?
|
||||
var searchText = ""
|
||||
|
||||
var filteredIssues: [Issue] {
|
||||
var result = issues
|
||||
if let statusFilter {
|
||||
result = result.filter { $0.status == statusFilter }
|
||||
}
|
||||
if !searchText.isEmpty {
|
||||
result = result.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.identifier.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Group issues by status for sectioned display
|
||||
var issuesByStatus: [(IssueStatus, [Issue])] {
|
||||
let grouped = Dictionary(grouping: filteredIssues, by: \.status)
|
||||
let order: [IssueStatus] = [.inProgress, .todo, .inReview, .blocked, .backlog, .done, .cancelled]
|
||||
return order.compactMap { status in
|
||||
guard let issues = grouped[status], !issues.isEmpty else { return nil }
|
||||
return (status, issues)
|
||||
}
|
||||
}
|
||||
|
||||
func loadIssues() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
issues = try await APIClient.shared.listIssues()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
do {
|
||||
issues = try await APIClient.shared.listIssues()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIssue(_ issue: Issue) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteIssue(issue.id)
|
||||
issues.removeAll { $0.id == issue.id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WorkspaceViewModel {
|
||||
var workspaces: [Workspace] = []
|
||||
var selectedWorkspace: Workspace?
|
||||
var members: [Member] = []
|
||||
var agents: [Agent] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
var hasSelectedWorkspace: Bool {
|
||||
selectedWorkspace != nil
|
||||
}
|
||||
|
||||
func loadWorkspaces() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
workspaces = try await APIClient.shared.listWorkspaces()
|
||||
// Auto-select if there's a saved workspace or only one
|
||||
if let savedId = APIClient.shared.workspaceId,
|
||||
let saved = workspaces.first(where: { $0.id == savedId }) {
|
||||
await selectWorkspace(saved)
|
||||
} else if workspaces.count == 1 {
|
||||
await selectWorkspace(workspaces[0])
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectWorkspace(_ workspace: Workspace) async {
|
||||
selectedWorkspace = workspace
|
||||
APIClient.shared.workspaceId = workspace.id
|
||||
WebSocketClient.shared.disconnect()
|
||||
WebSocketClient.shared.connect()
|
||||
await loadWorkspaceData()
|
||||
}
|
||||
|
||||
func loadWorkspaceData() async {
|
||||
guard let workspace = selectedWorkspace else { return }
|
||||
do {
|
||||
async let membersResult = APIClient.shared.listMembers(workspaceId: workspace.id)
|
||||
async let agentsResult = APIClient.shared.listAgents()
|
||||
members = try await membersResult
|
||||
agents = try await agentsResult
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// All possible assignees (members + agents)
|
||||
var assignees: [Assignee] {
|
||||
let memberAssignees = members.map { Assignee.member($0) }
|
||||
let agentAssignees = agents.map { Assignee.agent($0) }
|
||||
return memberAssignees + agentAssignees
|
||||
}
|
||||
|
||||
func assigneeName(type: String?, id: String?) -> String {
|
||||
guard let type, let id else { return "Unassigned" }
|
||||
if type == "agent" {
|
||||
return agents.first(where: { $0.id == id })?.name ?? "Agent"
|
||||
} else {
|
||||
return members.first(where: { $0.userId == id })?.displayName ?? "Member"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text("Multica")
|
||||
.font(.largeTitle.bold())
|
||||
Text("AI-native project management")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !viewModel.codeSent {
|
||||
emailForm
|
||||
} else {
|
||||
codeForm
|
||||
}
|
||||
|
||||
if let error = viewModel.error {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var emailForm: some View {
|
||||
VStack(spacing: 16) {
|
||||
TextField("Email address", text: $viewModel.email)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
Button {
|
||||
Task { await viewModel.sendCode() }
|
||||
} label: {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Send Code")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
private var codeForm: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Enter the 6-digit code sent to \(viewModel.email)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
TextField("Verification code", text: $viewModel.code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.title2.monospaced())
|
||||
|
||||
Button {
|
||||
Task { await viewModel.verifyCode() }
|
||||
} label: {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Verify")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(viewModel.code.isEmpty || viewModel.isLoading)
|
||||
|
||||
Button("Use a different email") {
|
||||
viewModel.codeSent = false
|
||||
viewModel.code = ""
|
||||
viewModel.error = nil
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommentListView: View {
|
||||
@Bindable var viewModel: IssueDetailViewModel
|
||||
@State private var newComment = ""
|
||||
@State private var isSending = false
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.comments.isEmpty && !viewModel.isLoading {
|
||||
Text("No comments yet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
ForEach(viewModel.comments) { comment in
|
||||
CommentRowView(comment: comment)
|
||||
if comment.id != viewModel.comments.last?.id {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
|
||||
// New comment input
|
||||
VStack(spacing: 8) {
|
||||
Divider()
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Add a comment...", text: $newComment, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
|
||||
Button {
|
||||
Task { await sendComment() }
|
||||
} label: {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
}
|
||||
}
|
||||
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendComment() async {
|
||||
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isSending = true
|
||||
await viewModel.addComment(text)
|
||||
newComment = ""
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentRowView: View {
|
||||
let comment: Comment
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
AssigneeAvatar(
|
||||
type: comment.authorType,
|
||||
name: comment.authorName ?? "?",
|
||||
size: 28
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(comment.authorName ?? (comment.isFromAgent ? "Agent" : "User"))
|
||||
.font(.subheadline.bold())
|
||||
if comment.isFromAgent {
|
||||
Text("BOT")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(.purple.opacity(0.15))
|
||||
.foregroundStyle(.purple)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
Spacer()
|
||||
Text(relativeDate(comment.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Text(comment.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func relativeDate(_ isoString: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
|
||||
return ""
|
||||
}
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .abbreviated
|
||||
return relative.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AssigneeAvatar: View {
|
||||
let type: String?
|
||||
let name: String
|
||||
var size: CGFloat = 24
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(type == "agent" ? Color.purple.opacity(0.2) : Color.gray.opacity(0.2))
|
||||
.frame(width: size, height: size)
|
||||
|
||||
if type == "agent" {
|
||||
Image(systemName: "cpu")
|
||||
.font(.system(size: size * 0.5))
|
||||
.foregroundStyle(.purple)
|
||||
} else {
|
||||
Text(initials)
|
||||
.font(.system(size: size * 0.4, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let parts = name.split(separator: " ")
|
||||
if parts.count >= 2 {
|
||||
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
|
||||
}
|
||||
return String(name.prefix(2)).uppercased()
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PriorityIcon: View {
|
||||
let priority: IssuePriority
|
||||
var size: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch priority {
|
||||
case .urgent:
|
||||
Image(systemName: "exclamationmark.3")
|
||||
.foregroundStyle(.red)
|
||||
case .high:
|
||||
Image(systemName: "exclamationmark.2")
|
||||
.foregroundStyle(.orange)
|
||||
case .medium:
|
||||
Image(systemName: "exclamationmark")
|
||||
.foregroundStyle(.yellow)
|
||||
case .low:
|
||||
Image(systemName: "arrow.down")
|
||||
.foregroundStyle(.blue)
|
||||
case .none:
|
||||
Image(systemName: "minus")
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.font(.system(size: size, weight: .medium))
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatusBadge: View {
|
||||
let status: IssueStatus
|
||||
|
||||
var body: some View {
|
||||
Label(status.label, systemImage: status.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case .backlog: .gray
|
||||
case .todo: .primary
|
||||
case .inProgress: .yellow
|
||||
case .inReview: .blue
|
||||
case .done: .green
|
||||
case .blocked: .red
|
||||
case .cancelled: .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusIcon: View {
|
||||
let status: IssueStatus
|
||||
var size: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: status.iconName)
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case .backlog: .gray
|
||||
case .todo: .primary
|
||||
case .inProgress: .yellow
|
||||
case .inReview: .blue
|
||||
case .done: .green
|
||||
case .blocked: .red
|
||||
case .cancelled: .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var authVM = AuthViewModel()
|
||||
@State private var workspaceVM = WorkspaceViewModel()
|
||||
@State private var issueListVM = IssueListViewModel()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !authVM.isAuthenticated {
|
||||
LoginView(viewModel: authVM)
|
||||
} else if !workspaceVM.hasSelectedWorkspace {
|
||||
WorkspacePickerView(viewModel: workspaceVM)
|
||||
} else {
|
||||
IssueListView(viewModel: issueListVM, workspaceVM: workspaceVM)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .logout)) { _ in
|
||||
authVM.logout()
|
||||
workspaceVM.selectedWorkspace = nil
|
||||
issueListVM.issues = []
|
||||
}
|
||||
.onChange(of: workspaceVM.hasSelectedWorkspace) { _, hasWorkspace in
|
||||
if hasWorkspace {
|
||||
Task { await issueListVM.loadIssues() }
|
||||
setupRealtimeSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupRealtimeSync() {
|
||||
WebSocketClient.shared.onPrefix("issue") { _ in
|
||||
Task { @MainActor in
|
||||
await issueListVM.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreateIssueView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Bindable var workspaceVM: WorkspaceViewModel
|
||||
var onCreate: (Issue) -> Void
|
||||
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var status: IssueStatus = .todo
|
||||
@State private var priority: IssuePriority = .none
|
||||
@State private var selectedAssignee: Assignee?
|
||||
@State private var isSubmitting = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Title", text: $title)
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
}
|
||||
|
||||
Section("Properties") {
|
||||
// Status picker
|
||||
Picker("Status", selection: $status) {
|
||||
ForEach(IssueStatus.allCases, id: \.self) { s in
|
||||
Label(s.label, systemImage: s.iconName).tag(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority picker
|
||||
Picker("Priority", selection: $priority) {
|
||||
ForEach(IssuePriority.allCases, id: \.self) { p in
|
||||
Label(p.label, systemImage: p.iconName).tag(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Assignee picker
|
||||
Picker("Assignee", selection: $selectedAssignee) {
|
||||
Text("Unassigned").tag(nil as Assignee?)
|
||||
ForEach(workspaceVM.assignees) { assignee in
|
||||
Label(
|
||||
assignee.name,
|
||||
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
|
||||
).tag(assignee as Assignee?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Issue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
Task { await createIssue() }
|
||||
}
|
||||
.disabled(title.isEmpty || isSubmitting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createIssue() async {
|
||||
isSubmitting = true
|
||||
error = nil
|
||||
do {
|
||||
let request = CreateIssueRequest(
|
||||
title: title,
|
||||
description: description.isEmpty ? nil : description,
|
||||
status: status.rawValue,
|
||||
priority: priority.rawValue,
|
||||
assigneeType: selectedAssignee?.typeName,
|
||||
assigneeId: selectedAssignee?.entityId
|
||||
)
|
||||
let issue = try await APIClient.shared.createIssue(request)
|
||||
onCreate(issue)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IssueDetailView: View {
|
||||
@Bindable var viewModel: IssueDetailViewModel
|
||||
@Bindable var workspaceVM: WorkspaceViewModel
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
issueHeader
|
||||
Divider()
|
||||
propertiesSection
|
||||
Divider()
|
||||
|
||||
// Tab bar
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Comments").tag(0)
|
||||
Text("Activity").tag(1)
|
||||
if viewModel.issue.isAssignedToAgent {
|
||||
Text("Agent Runs").tag(2)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding()
|
||||
|
||||
switch selectedTab {
|
||||
case 0:
|
||||
CommentListView(viewModel: viewModel)
|
||||
case 1:
|
||||
activitySection
|
||||
case 2:
|
||||
TaskRunsView(viewModel: viewModel)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.issue.identifier)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.loadAll()
|
||||
viewModel.setupRealtimeUpdates()
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { viewModel.error != nil },
|
||||
set: { if !$0 { viewModel.error = nil } }
|
||||
)) {
|
||||
Button("OK") { viewModel.error = nil }
|
||||
} message: {
|
||||
Text(viewModel.error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var issueHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(viewModel.issue.title)
|
||||
.font(.title2.bold())
|
||||
|
||||
if let desc = viewModel.issue.description, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Active task banner
|
||||
if let task = viewModel.activeTask, task.status.isActive {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Agent is working on this issue...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("View Logs") {
|
||||
selectedTab = 2
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.purple.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var propertiesSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Status
|
||||
propertyRow(label: "Status") {
|
||||
Menu {
|
||||
ForEach(IssueStatus.allCases, id: \.self) { status in
|
||||
Button {
|
||||
Task { await viewModel.updateStatus(status) }
|
||||
} label: {
|
||||
Label(status.label, systemImage: status.iconName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
StatusBadge(status: viewModel.issue.status)
|
||||
}
|
||||
}
|
||||
Divider().padding(.leading)
|
||||
|
||||
// Priority
|
||||
propertyRow(label: "Priority") {
|
||||
Menu {
|
||||
ForEach(IssuePriority.allCases, id: \.self) { priority in
|
||||
Button {
|
||||
Task { await viewModel.updatePriority(priority) }
|
||||
} label: {
|
||||
Label(priority.label, systemImage: priority.iconName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
PriorityIcon(priority: viewModel.issue.priority)
|
||||
Text(viewModel.issue.priority.label)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider().padding(.leading)
|
||||
|
||||
// Assignee
|
||||
propertyRow(label: "Assignee") {
|
||||
Menu {
|
||||
Button("Unassigned") {
|
||||
Task { await viewModel.updateAssignee(type: nil, id: nil) }
|
||||
}
|
||||
Divider()
|
||||
ForEach(workspaceVM.assignees) { assignee in
|
||||
Button {
|
||||
Task { await viewModel.updateAssignee(type: assignee.typeName, id: assignee.entityId) }
|
||||
} label: {
|
||||
Label(
|
||||
assignee.name,
|
||||
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
let name = workspaceVM.assigneeName(type: viewModel.issue.assigneeType, id: viewModel.issue.assigneeId)
|
||||
HStack(spacing: 6) {
|
||||
AssigneeAvatar(type: viewModel.issue.assigneeType, name: name, size: 20)
|
||||
Text(name)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func propertyRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
content()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var activitySection: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
Text("Activity timeline")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IssueListView: View {
|
||||
@Bindable var viewModel: IssueListViewModel
|
||||
@Bindable var workspaceVM: WorkspaceViewModel
|
||||
@State private var showCreateIssue = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.issues.isEmpty {
|
||||
ProgressView("Loading issues...")
|
||||
} else if viewModel.filteredIssues.isEmpty {
|
||||
ContentUnavailableView.search(text: viewModel.searchText)
|
||||
} else {
|
||||
issueList
|
||||
}
|
||||
}
|
||||
.navigationTitle(workspaceVM.selectedWorkspace?.name ?? "Issues")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search issues...")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showCreateIssue = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("All") { viewModel.statusFilter = nil }
|
||||
Divider()
|
||||
ForEach(IssueStatus.allCases, id: \.self) { status in
|
||||
Button {
|
||||
viewModel.statusFilter = status
|
||||
} label: {
|
||||
Label(status.label, systemImage: status.iconName)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: viewModel.statusFilter != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Menu {
|
||||
Button("Switch Workspace") {
|
||||
workspaceVM.selectedWorkspace = nil
|
||||
}
|
||||
Button("Logout", role: .destructive) {
|
||||
NotificationCenter.default.post(name: .logout, object: nil)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refresh()
|
||||
}
|
||||
.sheet(isPresented: $showCreateIssue) {
|
||||
CreateIssueView(workspaceVM: workspaceVM) { newIssue in
|
||||
viewModel.issues.insert(newIssue, at: 0)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadIssues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var issueList: some View {
|
||||
List {
|
||||
ForEach(viewModel.issuesByStatus, id: \.0) { status, issues in
|
||||
Section {
|
||||
ForEach(issues) { issue in
|
||||
NavigationLink(value: issue) {
|
||||
IssueRowView(
|
||||
issue: issue,
|
||||
assigneeName: workspaceVM.assigneeName(type: issue.assigneeType, id: issue.assigneeId)
|
||||
)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
let issue = issues[index]
|
||||
Task { await viewModel.deleteIssue(issue) }
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: 6) {
|
||||
StatusIcon(status: status, size: 14)
|
||||
Text(status.label)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("\(issues.count)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationDestination(for: Issue.self) { issue in
|
||||
IssueDetailView(
|
||||
viewModel: IssueDetailViewModel(issue: issue),
|
||||
workspaceVM: workspaceVM
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let logout = Notification.Name("logout")
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IssueRowView: View {
|
||||
let issue: Issue
|
||||
let assigneeName: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
StatusIcon(status: issue.status)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(issue.identifier)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
PriorityIcon(priority: issue.priority, size: 10)
|
||||
}
|
||||
|
||||
Text(issue.title)
|
||||
.font(.subheadline)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if issue.assigneeId != nil {
|
||||
AssigneeAvatar(
|
||||
type: issue.assigneeType,
|
||||
name: assigneeName,
|
||||
size: 24
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskMessagesView: View {
|
||||
let messages: [TaskMessage]
|
||||
let isLive: Bool
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
if messages.isEmpty {
|
||||
Text("No log messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
ForEach(messages) { message in
|
||||
TaskMessageRow(message: message)
|
||||
}
|
||||
|
||||
if isLive && !messages.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
Text("Streaming...")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskMessageRow: View {
|
||||
let message: TaskMessage
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
switch message.type {
|
||||
case .text:
|
||||
textMessage
|
||||
|
||||
case .thinking:
|
||||
thinkingMessage
|
||||
|
||||
case .toolUse:
|
||||
toolUseMessage
|
||||
|
||||
case .toolResult:
|
||||
toolResultMessage
|
||||
|
||||
case .error:
|
||||
errorMessage
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
|
||||
private var textMessage: some View {
|
||||
Text(message.content ?? "")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var thinkingMessage: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "brain")
|
||||
.font(.caption2)
|
||||
Text("Thinking")
|
||||
.font(.caption2.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(.purple.opacity(0.7))
|
||||
|
||||
if let content = message.content, !content.isEmpty {
|
||||
Text(content)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(isExpanded ? nil : 3)
|
||||
.onTapGesture { isExpanded.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolUseMessage: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench")
|
||||
.font(.caption2)
|
||||
Text(message.tool ?? "Tool")
|
||||
.font(.caption2.weight(.semibold).monospaced())
|
||||
}
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
if let content = message.content, !content.isEmpty {
|
||||
Text(content)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(isExpanded ? nil : 2)
|
||||
.onTapGesture { isExpanded.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolResultMessage: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.turn.down.left")
|
||||
.font(.caption2)
|
||||
Text(message.tool ?? "Result")
|
||||
.font(.caption2.weight(.medium).monospaced())
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
|
||||
if let output = message.output, !output.isEmpty {
|
||||
Text(output)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(isExpanded ? nil : 3)
|
||||
.onTapGesture { isExpanded.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var errorMessage: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.caption2)
|
||||
Text(message.content ?? "Error")
|
||||
.font(.caption2.monospaced())
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskRunsView: View {
|
||||
@Bindable var viewModel: IssueDetailViewModel
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.taskRuns.isEmpty && !viewModel.isLoading {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "cpu")
|
||||
.font(.title)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No agent runs yet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Assign this issue to an agent to trigger execution.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
// Active task with live logs
|
||||
if let activeTask = viewModel.activeTask, activeTask.status.isActive {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Running")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.purple)
|
||||
Spacer()
|
||||
Text("Started \(relativeDate(activeTask.startedAt ?? activeTask.createdAt))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
|
||||
TaskMessagesView(messages: viewModel.taskMessages, isLive: true)
|
||||
}
|
||||
.background(.purple.opacity(0.03))
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
// Historical runs
|
||||
ForEach(viewModel.taskRuns.filter { t in
|
||||
viewModel.activeTask.map { $0.id != t.id } ?? true
|
||||
}) { task in
|
||||
NavigationLink {
|
||||
TaskRunDetailView(task: task)
|
||||
} label: {
|
||||
TaskRunRow(task: task)
|
||||
}
|
||||
Divider().padding(.leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relativeDate(_ isoString: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
|
||||
return ""
|
||||
}
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .abbreviated
|
||||
return relative.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskRunRow: View {
|
||||
let task: AgentTask
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: task.status.iconName)
|
||||
.foregroundStyle(taskColor)
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(task.status.label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let error = task.error {
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(formatDate(task.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
if let started = task.startedAt, let completed = task.completedAt {
|
||||
Text(duration(from: started, to: completed))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var taskColor: Color {
|
||||
switch task.status {
|
||||
case .completed: .green
|
||||
case .failed: .red
|
||||
case .running: .purple
|
||||
case .cancelled: .gray
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ iso: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { return iso }
|
||||
let df = DateFormatter()
|
||||
df.dateStyle = .short
|
||||
df.timeStyle = .short
|
||||
return df.string(from: date)
|
||||
}
|
||||
|
||||
private func duration(from start: String, to end: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let s = formatter.date(from: start) ?? ISO8601DateFormatter().date(from: start),
|
||||
let e = formatter.date(from: end) ?? ISO8601DateFormatter().date(from: end) else { return "" }
|
||||
let interval = e.timeIntervalSince(s)
|
||||
if interval < 60 { return "\(Int(interval))s" }
|
||||
if interval < 3600 { return "\(Int(interval / 60))m \(Int(interval.truncatingRemainder(dividingBy: 60)))s" }
|
||||
return "\(Int(interval / 3600))h \(Int((interval.truncatingRemainder(dividingBy: 3600)) / 60))m"
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskRunDetailView: View {
|
||||
let task: AgentTask
|
||||
@State private var messages: [TaskMessage] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Task info header
|
||||
HStack {
|
||||
Image(systemName: task.status.iconName)
|
||||
.foregroundStyle(taskColor)
|
||||
Text(task.status.label)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if let error = task.error {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if isLoading {
|
||||
ProgressView("Loading execution log...")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
TaskMessagesView(messages: messages, isLive: false)
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
.navigationTitle("Run Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
do {
|
||||
messages = try await APIClient.shared.listTaskMessages(taskId: task.id)
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private var taskColor: Color {
|
||||
switch task.status {
|
||||
case .completed: .green
|
||||
case .failed: .red
|
||||
case .running: .purple
|
||||
case .cancelled: .gray
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WorkspacePickerView: View {
|
||||
@Bindable var viewModel: WorkspaceViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Loading workspaces...")
|
||||
} else if viewModel.workspaces.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Workspaces",
|
||||
systemImage: "folder",
|
||||
description: Text("You don't belong to any workspaces yet.")
|
||||
)
|
||||
} else {
|
||||
List(viewModel.workspaces) { workspace in
|
||||
Button {
|
||||
Task { await viewModel.selectWorkspace(workspace) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(workspace.name)
|
||||
.font(.headline)
|
||||
if let desc = workspace.description, !desc.isEmpty {
|
||||
Text(desc)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Workspaces")
|
||||
.task {
|
||||
await viewModel.loadWorkspaces()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
# Multica iOS App
|
||||
|
||||
MVP iOS client for the Multica platform — issue management with AI agent execution log viewing.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16+
|
||||
- iOS 17.0+
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (for generating the Xcode project)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install XcodeGen if you don't have it
|
||||
brew install xcodegen
|
||||
|
||||
# Generate Xcode project
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
# Open in Xcode
|
||||
open Multica.xcodeproj
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
By default, the app connects to `http://localhost:8080` in debug builds. To change the API URL, edit `Multica/Services/APIClient.swift`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication** — Passwordless email login (send code → verify)
|
||||
- **Workspace selection** — Pick from your workspaces
|
||||
- **Issue list** — Grouped by status, searchable, with status filtering
|
||||
- **Issue detail** — View/edit title, status, priority, and assignee
|
||||
- **Comments** — View and add comments with threaded display
|
||||
- **Agent task runs** — View all historical agent executions for an issue
|
||||
- **Execution logs** — Real-time streaming of agent tool use, thinking, and output
|
||||
- **Real-time sync** — WebSocket connection for live updates
|
||||
|
||||
## Architecture
|
||||
|
||||
- **SwiftUI** with `@Observable` (iOS 17+)
|
||||
- **MVVM** — ViewModels use `@Observable` macro
|
||||
- **URLSession** for HTTP networking
|
||||
- **URLSessionWebSocketTask** for real-time
|
||||
- **Keychain** for secure token storage
|
||||
- No third-party dependencies
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
Multica/
|
||||
├── MulticaApp.swift # App entry point
|
||||
├── Models/ # Codable data models
|
||||
├── Services/ # API client, WebSocket, Keychain
|
||||
├── ViewModels/ # @Observable view models
|
||||
└── Views/
|
||||
├── Auth/ # Login + code verification
|
||||
├── Workspace/ # Workspace picker
|
||||
├── Issues/ # List, detail, create
|
||||
├── Comments/ # Comment list + input
|
||||
├── Tasks/ # Agent runs + execution logs
|
||||
└── Components/ # Shared UI (badges, icons, avatars)
|
||||
```
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Multica
|
||||
options:
|
||||
bundleIdPrefix: ai.multica
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
DEVELOPMENT_TEAM: ""
|
||||
targets:
|
||||
Multica:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- Multica
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: Multica/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.multica.app
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
@@ -50,7 +50,7 @@ describe("LoginPage", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText("Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
|
||||
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Continue" })
|
||||
|
||||
@@ -286,7 +286,7 @@ function LoginPageContent() {
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Multica</CardTitle>
|
||||
<CardDescription>AI-native task management</CardDescription>
|
||||
<CardDescription>Turn coding agents into real teammates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import {
|
||||
Bot,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Lock,
|
||||
Settings,
|
||||
Camera,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -70,6 +71,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
@@ -328,6 +330,7 @@ function AgentListItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -336,11 +339,11 @@ function AgentListItem({
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className="rounded-lg" />
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{agent.name}</span>
|
||||
<span className={`truncate text-sm font-medium ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</span>
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
@@ -348,8 +351,14 @@ function AgentListItem({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground">Archived</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -380,6 +389,8 @@ function InstructionsTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -446,6 +457,8 @@ function SkillsTab({
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowPicker(false);
|
||||
@@ -458,6 +471,8 @@ function SkillsTab({
|
||||
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -701,6 +716,8 @@ function ToolsTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(tools);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -845,6 +862,8 @@ function TriggersTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(triggers);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1050,8 +1069,17 @@ function TasksTab({ agent }: { agent: Agent }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
Loading tasks...
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border px-4 py-3">
|
||||
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1346,30 +1374,50 @@ function AgentDetail({
|
||||
agent,
|
||||
runtimes,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onRestore,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onArchive: (id: string) => Promise<void>;
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Archive Banner */}
|
||||
{isArchived && (
|
||||
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">This agent is archived. It cannot be assigned or mentioned.</span>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={() => onRestore(agent.id)}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className="rounded-md" />
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{agent.name}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>
|
||||
{isArchived ? (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
) : (
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3" />
|
||||
@@ -1380,24 +1428,26 @@ function AgentDetail({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
{!isArchived && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -1451,33 +1501,33 @@ function AgentDetail({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{confirmDelete && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
{/* Archive Confirmation */}
|
||||
{confirmArchive && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
|
||||
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This will permanently delete "{agent.name}" and all its configuration.
|
||||
"{agent.name}" will be archived. It won't be assignable or mentionable, but all history is preserved. You can restore it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(agent.id);
|
||||
setConfirmArchive(false);
|
||||
onArchive(agent.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1497,6 +1547,7 @@ export default function AgentsPage() {
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
||||
@@ -1508,12 +1559,19 @@ export default function AgentsPage() {
|
||||
if (workspace) fetchRuntimes();
|
||||
}, [workspace, fetchRuntimes]);
|
||||
|
||||
// Select first agent on initial load
|
||||
const filteredAgents = useMemo(
|
||||
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
|
||||
[agents, showArchived],
|
||||
);
|
||||
|
||||
const archivedCount = useMemo(() => agents.filter((a) => !!a.archived_at).length, [agents]);
|
||||
|
||||
// Select first agent on initial load or when filter changes
|
||||
useEffect(() => {
|
||||
if (agents.length > 0 && !selectedId) {
|
||||
setSelectedId(agents[0]!.id);
|
||||
if (filteredAgents.length > 0 && !filteredAgents.some((a) => a.id === selectedId)) {
|
||||
setSelectedId(filteredAgents[0]!.id);
|
||||
}
|
||||
}, [agents, selectedId]);
|
||||
}, [filteredAgents, selectedId]);
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const agent = await api.createAgent(data);
|
||||
@@ -1522,25 +1580,74 @@ export default function AgentsPage() {
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
await refreshAgents();
|
||||
try {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
await refreshAgents();
|
||||
toast.success("Agent updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api.deleteAgent(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = agents.filter((a) => a.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveAgent(id);
|
||||
await refreshAgents();
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await api.restoreAgent(id);
|
||||
await refreshAgents();
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
}
|
||||
await refreshAgents();
|
||||
};
|
||||
|
||||
const selected = agents.find((a) => a.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* List skeleton */}
|
||||
<div className="w-72 border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Detail skeleton */}
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-3/4 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1557,30 +1664,46 @@ export default function AgentsPage() {
|
||||
<div className="overflow-y-auto h-full border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Agents</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{archivedCount > 0 && (
|
||||
<Button
|
||||
variant={showArchived ? "secondary" : "ghost"}
|
||||
size="icon-xs"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
title={showArchived ? "Show active agents" : "Show archived agents"}
|
||||
>
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Bot className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Agent
|
||||
</Button>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{showArchived ? "No archived agents" : archivedCount > 0 ? "No active agents" : "No agents yet"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{agents.map((agent) => (
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
@@ -1603,7 +1726,8 @@ export default function AgentsPage() {
|
||||
agent={selected}
|
||||
runtimes={runtimes}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
@@ -219,11 +220,20 @@ function InboxListItem({
|
||||
|
||||
export default function InboxPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const selectedKey = searchParams.get("issue") ?? "";
|
||||
const setSelectedKey = (key: string) => {
|
||||
const urlIssue = searchParams.get("issue") ?? "";
|
||||
|
||||
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
|
||||
|
||||
// Sync from URL when searchParams change (e.g. Next.js navigation)
|
||||
useEffect(() => {
|
||||
setSelectedKeyState(urlIssue);
|
||||
}, [urlIssue]);
|
||||
|
||||
const setSelectedKey = useCallback((key: string) => {
|
||||
setSelectedKeyState(key);
|
||||
const url = key ? `/inbox?issue=${key}` : "/inbox";
|
||||
window.history.replaceState(null, "", url);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const items = useInboxStore((s) => s.dedupedItems());
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
@@ -413,10 +423,11 @@ export default function InboxPage() {
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
{selected?.issue_id ? (
|
||||
<IssueDetail
|
||||
key={selected.issue_id}
|
||||
key={selected.id}
|
||||
issueId={selected.issue_id}
|
||||
defaultSidebarOpen={false}
|
||||
layoutId="multica_inbox_issue_detail_layout"
|
||||
highlightCommentId={selected.details?.comment_id ?? undefined}
|
||||
onDelete={() => {
|
||||
handleArchive(selected.id);
|
||||
}}
|
||||
|
||||
@@ -104,9 +104,9 @@ vi.mock("@/components/ui/calendar", () => ({
|
||||
Calendar: () => null,
|
||||
}));
|
||||
|
||||
// Mock RichTextEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/components/common/rich-text-editor", () => ({
|
||||
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
// Mock ContentEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/features/editor", () => ({
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -132,6 +132,27 @@ vi.mock("@/components/common/rich-text-editor", () => ({
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => valueRef.current,
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => onBlur?.(valueRef.current)}
|
||||
placeholder={placeholder}
|
||||
data-testid="title-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Markdown renderer
|
||||
@@ -164,6 +185,9 @@ vi.mock("@/shared/api", () => ({
|
||||
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listDependencies: vi.fn().mockResolvedValue([]),
|
||||
createDependency: vi.fn().mockResolvedValue({}),
|
||||
deleteDependency: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save, Plus, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import type { WorkspaceRepo } from "@/shared/types";
|
||||
|
||||
export function RepositoriesTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||
|
||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
setRepos(workspace?.repos ?? []);
|
||||
}, [workspace]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!workspace) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateWorkspace(workspace.id, { repos });
|
||||
updateWorkspace(updated);
|
||||
toast.success("Repositories saved");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save repositories");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRepo = () => {
|
||||
setRepos([...repos, { url: "", description: "" }]);
|
||||
};
|
||||
|
||||
const handleRemoveRepo = (index: number) => {
|
||||
setRepos(repos.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
|
||||
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
||||
};
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Repositories</h2>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
|
||||
</p>
|
||||
|
||||
{repos.map((repo, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Input
|
||||
type="url"
|
||||
value={repo.url}
|
||||
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="https://github.com/org/repo"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={repo.description}
|
||||
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="Description (e.g. Go backend + Next.js frontend)"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
{canManageWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveRepo(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canManageWorkspace && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="outline" size="sm" onClick={handleAddRepo}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add repository
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canManageWorkspace && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only admins and owners can manage repositories.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,17 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
@@ -33,13 +44,17 @@ export function TokensTab() {
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
|
||||
const loadTokens = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.listPersonalAccessTokens();
|
||||
setTokens(list);
|
||||
} catch {
|
||||
// ignore — tokens section simply stays empty
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load tokens");
|
||||
} finally {
|
||||
setTokensLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -117,7 +132,21 @@ export function TokensTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{tokens.length > 0 && (
|
||||
{tokensLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : tokens.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{tokens.map((t) => (
|
||||
<Card key={t.id}>
|
||||
@@ -135,7 +164,7 @@ export function TokensTab() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleRevokeToken(t.id)}
|
||||
onClick={() => setRevokeConfirmId(t.id)}
|
||||
disabled={tokenRevoking === t.id}
|
||||
aria-label={`Revoke ${t.name}`}
|
||||
>
|
||||
@@ -152,6 +181,29 @@ export function TokensTab() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AlertDialog open={!!revokeConfirmId} onOpenChange={(v) => { if (!v) setRevokeConfirmId(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke token</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This token will be permanently revoked and can no longer be used. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (revokeConfirmId) await handleRevokeToken(revokeConfirmId);
|
||||
setRevokeConfirmId(null);
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save, LogOut, Plus, Trash2 } from "lucide-react";
|
||||
import { Save, LogOut } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -21,7 +21,6 @@ import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import type { WorkspaceRepo } from "@/shared/types";
|
||||
|
||||
export function WorkspaceTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -34,7 +33,6 @@ export function WorkspaceTab() {
|
||||
const [name, setName] = useState(workspace?.name ?? "");
|
||||
const [description, setDescription] = useState(workspace?.description ?? "");
|
||||
const [context, setContext] = useState(workspace?.context ?? "");
|
||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
@@ -52,7 +50,6 @@ export function WorkspaceTab() {
|
||||
setName(workspace?.name ?? "");
|
||||
setDescription(workspace?.description ?? "");
|
||||
setContext(workspace?.context ?? "");
|
||||
setRepos(workspace?.repos ?? []);
|
||||
}, [workspace]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -63,7 +60,6 @@ export function WorkspaceTab() {
|
||||
name,
|
||||
description,
|
||||
context,
|
||||
repos,
|
||||
});
|
||||
updateWorkspace(updated);
|
||||
toast.success("Workspace settings saved");
|
||||
@@ -74,18 +70,6 @@ export function WorkspaceTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRepo = () => {
|
||||
setRepos([...repos, { url: "", description: "" }]);
|
||||
};
|
||||
|
||||
const handleRemoveRepo = (index: number) => {
|
||||
setRepos(repos.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
|
||||
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
||||
};
|
||||
|
||||
const handleLeaveWorkspace = () => {
|
||||
if (!workspace) return;
|
||||
setConfirmAction({
|
||||
@@ -191,69 +175,6 @@ export function WorkspaceTab() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Repositories */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Repositories</h2>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
|
||||
</p>
|
||||
|
||||
{repos.map((repo, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Input
|
||||
type="url"
|
||||
value={repo.url}
|
||||
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="https://github.com/org/repo"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={repo.description}
|
||||
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="Description (e.g. Go backend + Next.js frontend)"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
{canManageWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveRepo(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canManageWorkspace && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="outline" size="sm" onClick={handleAddRepo}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add repository
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim() || !canManageWorkspace}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { User, Palette, Key, Settings, Users } from "lucide-react";
|
||||
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { AccountTab } from "./_components/account-tab";
|
||||
@@ -8,6 +8,7 @@ import { AppearanceTab } from "./_components/general-tab";
|
||||
import { TokensTab } from "./_components/tokens-tab";
|
||||
import { WorkspaceTab } from "./_components/workspace-tab";
|
||||
import { MembersTab } from "./_components/members-tab";
|
||||
import { RepositoriesTab } from "./_components/repositories-tab";
|
||||
|
||||
const accountTabs = [
|
||||
{ value: "profile", label: "Profile", icon: User },
|
||||
@@ -17,6 +18,7 @@ const accountTabs = [
|
||||
|
||||
const workspaceTabs = [
|
||||
{ value: "workspace", label: "General", icon: Settings },
|
||||
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
|
||||
{ value: "members", label: "Members", icon: Users },
|
||||
];
|
||||
|
||||
@@ -60,6 +62,7 @@ export default function SettingsPage() {
|
||||
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
|
||||
<TabsContent value="tokens"><TokensTab /></TabsContent>
|
||||
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
|
||||
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
|
||||
<TabsContent value="members"><MembersTab /></TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,13 @@ export const metadata: Metadata = {
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
|
||||
@@ -3,33 +3,28 @@
|
||||
import { useRef } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
onUpload: (file: File) => Promise<UploadResult | null>;
|
||||
onInsert?: (result: UploadResult, isImage: boolean) => void;
|
||||
/** Called with the selected File — caller handles upload. */
|
||||
onSelect: (file: File) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
|
||||
function FileUploadButton({
|
||||
onUpload,
|
||||
onInsert,
|
||||
onSelect,
|
||||
disabled,
|
||||
className,
|
||||
size = "default",
|
||||
}: FileUploadButtonProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
const result = await onUpload(file);
|
||||
if (result && onInsert) {
|
||||
onInsert(result, file.type.startsWith("image/"));
|
||||
}
|
||||
onSelect(file);
|
||||
};
|
||||
|
||||
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/* Rich text editor: ProseMirror styles using shadcn design tokens */
|
||||
|
||||
.rich-text-editor.ProseMirror {
|
||||
color: var(--foreground);
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-text-editor.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.rich-text-editor .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.rich-text-editor h1 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rich-text-editor h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rich-text-editor h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.rich-text-editor p {
|
||||
margin-top: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
/* First child should not have top margin */
|
||||
.rich-text-editor > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Last child should not have bottom margin */
|
||||
.rich-text-editor > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.rich-text-editor ul {
|
||||
list-style-type: disc;
|
||||
padding-inline-start: 1.25rem;
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ol {
|
||||
list-style-type: decimal;
|
||||
padding-inline-start: 1.25rem;
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor li {
|
||||
margin: 0.125rem 0;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.rich-text-editor li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.rich-text-editor code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8em;
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: calc(var(--radius) * 0.6);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.rich-text-editor pre {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rich-text-editor pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — lowlight (hljs) */
|
||||
.rich-text-editor .hljs-keyword,
|
||||
.rich-text-editor .hljs-selector-tag,
|
||||
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
|
||||
|
||||
.rich-text-editor .hljs-string,
|
||||
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
|
||||
|
||||
.rich-text-editor .hljs-comment,
|
||||
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
|
||||
|
||||
.rich-text-editor .hljs-number,
|
||||
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
|
||||
|
||||
.rich-text-editor .hljs-title,
|
||||
.rich-text-editor .hljs-section,
|
||||
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
|
||||
|
||||
.rich-text-editor .hljs-attr,
|
||||
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
|
||||
|
||||
.rich-text-editor .hljs-variable,
|
||||
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
|
||||
|
||||
.rich-text-editor .hljs-type,
|
||||
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
|
||||
|
||||
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
|
||||
|
||||
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .rich-text-editor .hljs-keyword,
|
||||
.dark .rich-text-editor .hljs-selector-tag,
|
||||
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
|
||||
|
||||
.dark .rich-text-editor .hljs-string,
|
||||
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
|
||||
|
||||
.dark .rich-text-editor .hljs-number,
|
||||
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
|
||||
|
||||
.dark .rich-text-editor .hljs-title,
|
||||
.dark .rich-text-editor .hljs-section,
|
||||
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
|
||||
|
||||
.dark .rich-text-editor .hljs-attr,
|
||||
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
|
||||
|
||||
.dark .rich-text-editor .hljs-variable,
|
||||
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
|
||||
|
||||
.dark .rich-text-editor .hljs-type,
|
||||
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
|
||||
|
||||
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
|
||||
|
||||
/* Blockquotes */
|
||||
.rich-text-editor blockquote {
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.rich-text-editor hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.rich-text-editor a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rich-text-editor a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mentions */
|
||||
.rich-text-editor .mention {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
/* Strong / emphasis */
|
||||
.rich-text-editor strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-editor em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor s {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Uploading image placeholder (blob: URLs = in-flight uploads) */
|
||||
.rich-text-editor img[src^="blob:"] {
|
||||
opacity: 0.5;
|
||||
border-radius: var(--radius);
|
||||
animation: rte-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { Extension, mergeAttributes } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import "./rich-text-editor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RichTextEditorProps {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => void;
|
||||
onBlur?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
}
|
||||
|
||||
interface RichTextEditorRef {
|
||||
getMarkdown: () => string;
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => void;
|
||||
}
|
||||
|
||||
const LinkExtension = Link.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary hover:underline cursor-pointer",
|
||||
},
|
||||
});
|
||||
|
||||
const MentionExtension = Mention.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
suggestion: createMentionSuggestion(),
|
||||
}).extend({
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": "mention" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-mention-type": node.attrs.type ?? "member",
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
),
|
||||
`@${node.attrs.label ?? node.attrs.id}`,
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@[^\]]+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(
|
||||
/^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("mention", token.attributes);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
return `[@${label ?? id}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit shortcut extension (Mod+Enter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSubmitExtension(onSubmit: () => void) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown paste extension — parse pasted markdown text as rich text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMarkdownPasteExtension() {
|
||||
return Extension.create({
|
||||
name: "markdownPaste",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownPaste"),
|
||||
props: {
|
||||
clipboardTextParser(text, _context, plainText) {
|
||||
if (!plainText && editor.markdown) {
|
||||
const json = editor.markdown.parse(text);
|
||||
const node = editor.schema.nodeFromJSON(json);
|
||||
return Slice.maxOpen(node.content);
|
||||
}
|
||||
// Plain text fallback
|
||||
const p = editor.schema.nodes.paragraph!;
|
||||
const doc = editor.schema.nodes.doc!;
|
||||
const paragraph = p.create(null, text ? editor.schema.text(text) : undefined);
|
||||
return new Slice(doc.create(null, paragraph).content, 0, 0);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File upload extension (paste + drop) with blob URL instant preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function removeImageBySrc(editor: ReturnType<typeof useEditor>, src: string) {
|
||||
if (!editor) return;
|
||||
const { tr } = editor.state;
|
||||
let deleted = false;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (deleted) return false;
|
||||
if (node.type.name === "image" && node.attrs.src === src) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
deleted = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (deleted) editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "fileUpload",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const handleFiles = async (files: FileList, pos?: number) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
|
||||
let handled = false;
|
||||
for (const file of Array.from(files)) {
|
||||
handled = true;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
if (isImage) {
|
||||
// Instant preview via blob URL, then replace with real URL after upload
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
if (pos !== undefined) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(pos, {
|
||||
type: "image",
|
||||
attrs: { src: blobUrl, alt: file.name },
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: blobUrl, alt: file.name })
|
||||
.run();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (result) {
|
||||
const { tr } = editor.state;
|
||||
editor.state.doc.descendants((node, nodePos) => {
|
||||
if (
|
||||
node.type.name === "image" &&
|
||||
node.attrs.src === blobUrl
|
||||
) {
|
||||
tr.setNodeMarkup(nodePos, undefined, {
|
||||
...node.attrs,
|
||||
src: result.link,
|
||||
alt: result.filename,
|
||||
});
|
||||
}
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
} else {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
}
|
||||
} catch {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// Non-image: upload first, then insert link
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (!result) continue;
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, linkText).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(linkText).run();
|
||||
}
|
||||
} catch {
|
||||
// Upload errors handled by the hook/caller via toast
|
||||
}
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("fileUpload"),
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = event.clipboardData?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(_view, event) {
|
||||
const files = (event as DragEvent).dataTransfer?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
||||
function RichTextEditor(
|
||||
{
|
||||
defaultValue = "",
|
||||
onUpdate,
|
||||
placeholder: placeholderText = "",
|
||||
editable = true,
|
||||
className,
|
||||
debounceMs = 300,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onUploadFile,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
|
||||
// Helper to get markdown from @tiptap/markdown extension.
|
||||
// Post-processes mention shortcodes [@ id="..." label="..."] → markdown
|
||||
// links, using the Tiptap JSON doc for type info, in case the
|
||||
// renderMarkdown override doesn't take effect.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getEditorMarkdown = (ed: any): string => {
|
||||
const md: string = ed?.getMarkdown?.() ?? "";
|
||||
if (!md || !md.includes("[@ ")) return md;
|
||||
|
||||
// Build type map from editor JSON (which always has the type attr)
|
||||
const json = ed?.getJSON?.();
|
||||
const typeMap = new Map<string, string>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function walk(node: any) {
|
||||
if (node?.type === "mention" && node.attrs?.id) {
|
||||
typeMap.set(node.attrs.id, node.attrs.type || "member");
|
||||
}
|
||||
if (node?.content) node.content.forEach(walk);
|
||||
}
|
||||
if (json) walk(json);
|
||||
|
||||
return md.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match: string, attrString: string) => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /(\w+)="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
|
||||
}
|
||||
const { id, label } = attrs;
|
||||
if (!id || !label) return match;
|
||||
const type = typeMap.get(id) || "member";
|
||||
const display = type === "issue" ? label : `@${label}`;
|
||||
return `[${display}](mention://${type}/${id})`;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Keep refs in sync without recreating editor
|
||||
onUpdateRef.current = onUpdate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
onBlurRef.current = onBlur;
|
||||
onUploadFileRef.current = onUploadFile;
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
content: defaultValue || "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight }),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholderText,
|
||||
}),
|
||||
LinkExtension,
|
||||
Typography,
|
||||
MentionExtension,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
}),
|
||||
Markdown,
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => onSubmitRef.current?.()),
|
||||
createFileUploadExtension(onUploadFileRef),
|
||||
],
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdateRef.current?.(ed.getMarkdown());
|
||||
}, debounceMs);
|
||||
},
|
||||
onBlur: () => {
|
||||
onBlurRef.current?.();
|
||||
},
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
click(_view, event) {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
const link = (event.target as HTMLElement).closest("a");
|
||||
const href = link?.getAttribute("href");
|
||||
if (href && !href.startsWith("mention://")) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
class: cn("rich-text-editor text-sm outline-none", className),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||
clearContent: () => {
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
},
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => {
|
||||
if (!editor) return;
|
||||
if (isImage) {
|
||||
editor.chain().focus().setImage({ src: url, alt: filename }).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
},
|
||||
);
|
||||
|
||||
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };
|
||||
@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { preprocessMentionShortcodes } from './mentions'
|
||||
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
|
||||
|
||||
/**
|
||||
@@ -53,27 +54,6 @@ function urlTransform(url: string): string {
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
|
||||
* link format [@LABEL](mention://member/UUID) so they render as styled mentions.
|
||||
*/
|
||||
function preprocessMentionShortcodes(text: string): string {
|
||||
if (!text.includes('[@ ')) return text
|
||||
return text.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match, attrString: string) => {
|
||||
const attrs: Record<string, string> = {}
|
||||
const re = /(\w+)="([^"]*)"/g
|
||||
let m
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2]
|
||||
}
|
||||
const { id, label } = attrs
|
||||
if (!id || !label) return match
|
||||
return `[@${label}](mention://member/${id})`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// File path detection regex - matches paths starting with /, ~/, or ./
|
||||
const FILE_PATH_REGEX =
|
||||
|
||||
@@ -2,3 +2,4 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from
|
||||
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
|
||||
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
|
||||
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
|
||||
export { preprocessMentionShortcodes } from './mentions'
|
||||
|
||||
25
apps/web/components/markdown/mentions.ts
Normal file
25
apps/web/components/markdown/mentions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to the
|
||||
* standard markdown link format [@LABEL](mention://member/UUID).
|
||||
*
|
||||
* These shortcodes exist in older database records from a previous mention
|
||||
* serialization format. This function normalises them so downstream parsers
|
||||
* (Tiptap @tiptap/markdown, react-markdown) only need to handle one syntax.
|
||||
*/
|
||||
export function preprocessMentionShortcodes(text: string): string {
|
||||
if (!text.includes("[@ ")) return text;
|
||||
return text.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match, attrString: string) => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /(\w+)="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
|
||||
}
|
||||
const { id, label } = attrs;
|
||||
if (!id || !label) return match;
|
||||
return `[@${label}](mention://member/${id})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
389
apps/web/features/editor/content-editor.css
Normal file
389
apps/web/features/editor/content-editor.css
Normal file
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* ContentEditor typography — ProseMirror styles using shadcn design tokens.
|
||||
*
|
||||
* Design tier: "Compact" (same tier as Linear, Slack). Optimized for short-form
|
||||
* content (issue descriptions, comments) that users scan, not long-form reading.
|
||||
*
|
||||
* Typography values benchmarked against (April 2026):
|
||||
* - github-markdown-css (GitHub's markdown renderer)
|
||||
* - @tailwindcss/typography prose-sm preset
|
||||
* - Linear's editor (Tiptap-based, 14px body)
|
||||
*
|
||||
* Key decisions:
|
||||
* Body: 14px (text-sm), line-height 1.625 (between GitHub 1.5 and Tailwind 1.714)
|
||||
* Headings: h1=22px (1.57x), h2=18px (1.29x), h3=15px (1.07x) — compact but
|
||||
* with clear hierarchy. Previous h3 was 14px (same as body = no differentiation).
|
||||
* Paragraph spacing: 10px (was 8px; GitHub uses 10px, Tailwind prose-sm uses 16px)
|
||||
* List indent: 20px for ul (was 16px; standard is 22-32px)
|
||||
* Code block margin: 12px (was 8px; gives breathing room between code and prose)
|
||||
* Blockquote border: 3px (was 2px; GitHub/Tailwind both use 4px)
|
||||
* Links: var(--brand) blue with 40% opacity underline (was var(--primary) near-black)
|
||||
*
|
||||
* Inline elements (mention cards, inline code) that exceed line-height:
|
||||
* The browser auto-expands the line box for lines containing taller inline
|
||||
* elements. Controlled via vertical-align on [data-node-view-wrapper] and
|
||||
* box-decoration-break: clone on inline code.
|
||||
*/
|
||||
|
||||
.rich-text-editor.ProseMirror {
|
||||
color: var(--foreground);
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-text-editor.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.rich-text-editor .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Headings — compact but with clear visual hierarchy */
|
||||
.rich-text-editor h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rich-text-editor h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.rich-text-editor h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.rich-text-editor p {
|
||||
margin-top: 0.625rem;
|
||||
margin-bottom: 0.625rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
/* First child should not have top margin */
|
||||
.rich-text-editor > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Last child should not have bottom margin */
|
||||
.rich-text-editor > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.rich-text-editor ul {
|
||||
list-style-type: disc;
|
||||
padding-inline-start: 1.25rem;
|
||||
padding-inline-end: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ol {
|
||||
list-style-type: decimal;
|
||||
padding-inline-start: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor li {
|
||||
margin: 0.25rem 0;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.rich-text-editor li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.rich-text-editor li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Remove paragraph margins inside list items (Tiptap wraps li content in <p>) */
|
||||
.rich-text-editor li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor li > p + p {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Nested lists — bullet style progression and tighter spacing */
|
||||
.rich-text-editor ul ul {
|
||||
list-style-type: circle;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.rich-text-editor ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.rich-text-editor code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.875rem;
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 75%, transparent);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.rich-text-editor pre {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.75rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rich-text-editor pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — lowlight (hljs) */
|
||||
.rich-text-editor .hljs-keyword,
|
||||
.rich-text-editor .hljs-selector-tag,
|
||||
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
|
||||
|
||||
.rich-text-editor .hljs-string,
|
||||
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
|
||||
|
||||
.rich-text-editor .hljs-comment,
|
||||
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
|
||||
|
||||
.rich-text-editor .hljs-number,
|
||||
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
|
||||
|
||||
.rich-text-editor .hljs-title,
|
||||
.rich-text-editor .hljs-section,
|
||||
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
|
||||
|
||||
.rich-text-editor .hljs-attr,
|
||||
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
|
||||
|
||||
.rich-text-editor .hljs-variable,
|
||||
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
|
||||
|
||||
.rich-text-editor .hljs-type,
|
||||
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
|
||||
|
||||
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
|
||||
|
||||
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .rich-text-editor .hljs-keyword,
|
||||
.dark .rich-text-editor .hljs-selector-tag,
|
||||
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
|
||||
|
||||
.dark .rich-text-editor .hljs-string,
|
||||
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
|
||||
|
||||
.dark .rich-text-editor .hljs-number,
|
||||
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
|
||||
|
||||
.dark .rich-text-editor .hljs-title,
|
||||
.dark .rich-text-editor .hljs-section,
|
||||
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
|
||||
|
||||
.dark .rich-text-editor .hljs-attr,
|
||||
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
|
||||
|
||||
.dark .rich-text-editor .hljs-variable,
|
||||
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
|
||||
|
||||
.dark .rich-text-editor .hljs-type,
|
||||
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
|
||||
|
||||
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
|
||||
|
||||
/* Tables */
|
||||
.rich-text-editor .tableWrapper {
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.rich-text-editor table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rich-text-editor colgroup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rich-text-editor thead {
|
||||
background: color-mix(in srgb, var(--muted) 50%, transparent);
|
||||
}
|
||||
|
||||
.rich-text-editor tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rich-text-editor tr:hover td {
|
||||
background: color-mix(in srgb, var(--muted) 30%, transparent);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.rich-text-editor th,
|
||||
.rich-text-editor td {
|
||||
text-align: left;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rich-text-editor th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Remove paragraph margin inside table cells */
|
||||
.rich-text-editor th p,
|
||||
.rich-text-editor td p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.rich-text-editor blockquote {
|
||||
border-left: 3px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent);
|
||||
padding-left: 0.75rem;
|
||||
margin: 0.625rem 0;
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote p {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor blockquote blockquote {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-left-color: color-mix(in srgb, var(--muted-foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.rich-text-editor hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.rich-text-editor a {
|
||||
color: var(--brand);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, var(--brand) 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rich-text-editor a:hover {
|
||||
text-decoration-color: var(--brand);
|
||||
}
|
||||
|
||||
/* Issue mention cards — inline cards that sit within text flow */
|
||||
.rich-text-editor a.issue-mention {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rich-text-editor a.issue-mention:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Mentions */
|
||||
.rich-text-editor .mention {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
/* Strong / emphasis */
|
||||
.rich-text-editor strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-editor em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor s {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Readonly mode overrides */
|
||||
.rich-text-editor.readonly.ProseMirror {
|
||||
caret-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Mention NodeView inline layout fix */
|
||||
.rich-text-editor [data-node-view-wrapper] {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Images — shared styling for both editing and readonly */
|
||||
.rich-text-editor img {
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */
|
||||
.rich-text-editor img[data-uploading] {
|
||||
opacity: 0.5;
|
||||
border-radius: var(--radius);
|
||||
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-upload-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
198
apps/web/features/editor/content-editor.tsx
Normal file
198
apps/web/features/editor/content-editor.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ContentEditor — the single rich-text editor for the entire application.
|
||||
*
|
||||
* Architecture decisions (April 2026 refactor):
|
||||
*
|
||||
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
|
||||
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
|
||||
* separate components with duplicated extension configs — this caused
|
||||
* visual inconsistency between edit and display modes.
|
||||
*
|
||||
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
|
||||
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
|
||||
* Previously we had a custom `markdownToHtml()` pipeline (Marked library)
|
||||
* for loading and regex post-processing for saving — two asymmetric paths
|
||||
* that caused roundtrip inconsistencies. The @tiptap/markdown extension
|
||||
* (v3.21.0+) handles table cell <p> wrapping and custom mention tokenizers
|
||||
* natively, eliminating the need for the HTML detour.
|
||||
*
|
||||
* 3. PREPROCESSING is minimal: only legacy mention shortcode migration and
|
||||
* URL linkification (preprocessMarkdown). No HTML conversion.
|
||||
*
|
||||
* Tech: Tiptap v3.22.1 (ProseMirror wrapper), @tiptap/markdown for
|
||||
* bidirectional Markdown ↔ ProseMirror JSON conversion.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { createEditorExtensions } from "./extensions";
|
||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import "./content-editor.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ContentEditorProps {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => void;
|
||||
onBlur?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
}
|
||||
|
||||
interface ContentEditorRef {
|
||||
getMarkdown: () => string;
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
uploadFile: (file: File) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
function ContentEditor(
|
||||
{
|
||||
defaultValue = "",
|
||||
onUpdate,
|
||||
placeholder: placeholderText = "",
|
||||
editable = true,
|
||||
className,
|
||||
debounceMs = 300,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onUploadFile,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
const prevContentRef = useRef(defaultValue);
|
||||
|
||||
// Keep refs in sync without recreating editor
|
||||
onUpdateRef.current = onUpdate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
onBlurRef.current = onBlur;
|
||||
onUploadFileRef.current = onUploadFile;
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: createEditorExtensions({
|
||||
editable,
|
||||
placeholder: placeholderText,
|
||||
onSubmitRef,
|
||||
onUploadFileRef,
|
||||
}),
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdateRef.current?.(ed.getMarkdown());
|
||||
}, debounceMs);
|
||||
},
|
||||
onBlur: () => {
|
||||
onBlurRef.current?.();
|
||||
},
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
click(_view, event) {
|
||||
const target = event.target as HTMLElement;
|
||||
// Skip links inside NodeView wrappers — they handle their own clicks
|
||||
if (target.closest("[data-node-view-wrapper]")) return false;
|
||||
|
||||
const link = target.closest("a");
|
||||
const href = link?.getAttribute("href");
|
||||
if (!href || href.startsWith("mention://")) return false;
|
||||
|
||||
if (!editable) {
|
||||
// Readonly: any click on link opens new tab
|
||||
event.preventDefault();
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
// Edit mode: Cmd/Ctrl+click opens link
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
class: cn(
|
||||
"rich-text-editor text-sm outline-none",
|
||||
!editable && "readonly",
|
||||
className,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Readonly content update: when defaultValue changes and editor is readonly,
|
||||
// re-set the content (e.g. after editing a comment, the readonly view updates)
|
||||
useEffect(() => {
|
||||
if (!editor || editable) return;
|
||||
if (defaultValue === prevContentRef.current) return;
|
||||
prevContentRef.current = defaultValue;
|
||||
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
||||
if (processed) {
|
||||
editor.commands.setContent(processed, { contentType: "markdown" });
|
||||
} else {
|
||||
editor.commands.clearContent();
|
||||
}
|
||||
}, [editor, editable, defaultValue]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||
clearContent: () => {
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
},
|
||||
uploadFile: (file: File) => {
|
||||
if (!editor || !onUploadFileRef.current) return;
|
||||
const endPos = editor.state.doc.content.size;
|
||||
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
|
||||
},
|
||||
}));
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
},
|
||||
);
|
||||
|
||||
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };
|
||||
119
apps/web/features/editor/extensions/file-upload.ts
Normal file
119
apps/web/features/editor/extensions/file-upload.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function removeImageBySrc(editor: any, src: string) {
|
||||
if (!editor) return;
|
||||
const { tr } = editor.state;
|
||||
let deleted = false;
|
||||
editor.state.doc.descendants((node: any, pos: number) => {
|
||||
if (deleted) return false;
|
||||
if (node.type.name === "image" && node.attrs.src === src) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
deleted = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (deleted) editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared upload flow: insert blob preview → upload → replace with real URL.
|
||||
* Used by both paste/drop (at cursor) and button upload (at end of doc).
|
||||
*/
|
||||
export async function uploadAndInsertFile(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editor: any,
|
||||
file: File,
|
||||
handler: (file: File) => Promise<UploadResult | null>,
|
||||
pos?: number,
|
||||
) {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
if (isImage) {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
|
||||
} else {
|
||||
editor.chain().focus().setImage(imgAttrs).run();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (result) {
|
||||
const { tr } = editor.state;
|
||||
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
|
||||
if (node.type.name === "image" && node.attrs.src === blobUrl) {
|
||||
tr.setNodeMarkup(nodePos, undefined, {
|
||||
...node.attrs,
|
||||
src: result.link,
|
||||
alt: result.filename,
|
||||
uploading: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
} else {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
}
|
||||
} catch {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// Non-image: upload first, then insert link
|
||||
const result = await handler(file);
|
||||
if (!result) return;
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, linkText).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(linkText).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "fileUpload",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const handleFiles = async (files: FileList) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
for (const file of Array.from(files)) {
|
||||
await uploadAndInsertFile(editor, file, handler);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("fileUpload"),
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = event.clipboardData?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(_view, event) {
|
||||
const files = (event as DragEvent).dataTransfer?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
125
apps/web/features/editor/extensions/index.ts
Normal file
125
apps/web/features/editor/extensions/index.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Shared extension factory for ContentEditor.
|
||||
*
|
||||
* One function builds the extension array for BOTH edit and readonly modes.
|
||||
* This ensures visual consistency — the same extensions parse and render
|
||||
* content identically regardless of mode.
|
||||
*
|
||||
* Split:
|
||||
* - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention
|
||||
* - Edit only: Typography, Placeholder, markdownPaste, submitShortcut,
|
||||
* fileUpload, Mention suggestion popup
|
||||
*
|
||||
* Link config differs: edit mode has autolink (detects URLs while typing),
|
||||
* readonly does not (prevents false positives on display).
|
||||
*
|
||||
* Mention suggestion is only attached in edit mode — readonly doesn't need
|
||||
* the autocomplete popup.
|
||||
*
|
||||
* All link styling is controlled by content-editor.css (var(--brand) color),
|
||||
* not Tailwind HTMLAttributes, to keep a single source of truth.
|
||||
*/
|
||||
import type { RefObject } from "react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { BaseMentionExtension } from "./mention-extension";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import { createMarkdownPasteExtension } from "./markdown-paste";
|
||||
import { createSubmitExtension } from "./submit-shortcut";
|
||||
import { createFileUploadExtension } from "./file-upload";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const LinkEditable = Link.extend({ inclusive: false }).configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
linkOnPaste: false,
|
||||
});
|
||||
|
||||
const LinkReadonly = Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
});
|
||||
|
||||
const ImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
uploading: {
|
||||
default: false,
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.uploading ? { "data-uploading": "" } : {},
|
||||
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
});
|
||||
|
||||
export interface EditorExtensionsOptions {
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
onSubmitRef?: RefObject<(() => void) | undefined>;
|
||||
onUploadFileRef?: RefObject<
|
||||
((file: File) => Promise<UploadResult | null>) | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export function createEditorExtensions(
|
||||
options: EditorExtensionsOptions,
|
||||
): AnyExtension[] {
|
||||
const { editable, placeholder: placeholderText } = options;
|
||||
|
||||
const extensions: AnyExtension[] = [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight }),
|
||||
editable ? LinkEditable : LinkReadonly,
|
||||
ImageExtension,
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown,
|
||||
BaseMentionExtension.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
...(editable ? { suggestion: createMentionSuggestion() } : {}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (editable) {
|
||||
extensions.push(
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => options.onSubmitRef?.current?.()),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
67
apps/web/features/editor/extensions/markdown-paste.ts
Normal file
67
apps/web/features/editor/extensions/markdown-paste.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Markdown paste extension — ensures pasted text is parsed as Markdown.
|
||||
*
|
||||
* Problem: The browser clipboard can contain BOTH text/plain and text/html.
|
||||
* ProseMirror always prefers text/html when present (hardcoded in
|
||||
* parseFromClipboard: `let asText = !html`). When copying from VS Code,
|
||||
* text editors, or .md files, the OS wraps text in <pre>/<div> HTML tags.
|
||||
* ProseMirror parses these as code blocks — wrong.
|
||||
*
|
||||
* Solution: Use `handlePaste` (the only ProseMirror prop that runs for ALL
|
||||
* paste events and has access to raw ClipboardEvent). We check for
|
||||
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
|
||||
* own clipboard serializer. If present, the source is another ProseMirror
|
||||
* editor and its HTML is structurally correct — let ProseMirror handle it.
|
||||
* Otherwise, ignore the HTML and parse text/plain as Markdown.
|
||||
*
|
||||
* Why not clipboardTextParser? It only runs when there's NO text/html on
|
||||
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
|
||||
*
|
||||
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
|
||||
* VS Code's HTML contains <code> tags that fool rich-content detectors.
|
||||
* Markdown pattern matching has too many edge cases. The data-pm-slice
|
||||
* check is deterministic — no false positives.
|
||||
*/
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
|
||||
export function createMarkdownPasteExtension() {
|
||||
return Extension.create({
|
||||
name: "markdownPaste",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownPaste"),
|
||||
props: {
|
||||
handlePaste(view, event) {
|
||||
if (!editor.markdown) return false;
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) return false;
|
||||
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
const html = clipboard.getData("text/html");
|
||||
|
||||
// If HTML contains data-pm-slice, the source is another
|
||||
// ProseMirror editor — let ProseMirror use its native HTML
|
||||
// clipboard path to preserve exact node structure.
|
||||
if (html && html.includes("data-pm-slice")) return false;
|
||||
|
||||
// Everything else (VS Code, text editors, .md files, terminals,
|
||||
// web pages): parse text/plain as Markdown.
|
||||
const json = editor.markdown.parse(text);
|
||||
const node = editor.schema.nodeFromJSON(json);
|
||||
const slice = Slice.maxOpen(node.content);
|
||||
const tr = view.state.tr.replaceSelection(slice);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
64
apps/web/features/editor/extensions/mention-extension.ts
Normal file
64
apps/web/features/editor/extensions/mention-extension.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { MentionView } from "./mention-view";
|
||||
|
||||
export const BaseMentionExtension = Mention.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const type = node.attrs.type ?? "member";
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": "mention" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-mention-type": node.attrs.type ?? "member",
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
),
|
||||
`${prefix}${node.attrs.label ?? node.attrs.id}`,
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(
|
||||
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("mention", token.attributes);
|
||||
},
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Hash, Users } from "lucide-react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -24,8 +26,10 @@ export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent" | "issue" | "all";
|
||||
/** Secondary text shown below the label (e.g. issue title) */
|
||||
/** Secondary text shown beside the label (e.g. issue title) */
|
||||
description?: string;
|
||||
/** Issue status for StatusIcon rendering */
|
||||
status?: IssueStatus;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
@@ -37,6 +41,33 @@ export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group items by section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MentionGroup {
|
||||
label: string;
|
||||
items: MentionItem[];
|
||||
}
|
||||
|
||||
function groupItems(items: MentionItem[]): MentionGroup[] {
|
||||
const users: MentionItem[] = [];
|
||||
const issues: MentionItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "issue") {
|
||||
issues.push(item);
|
||||
} else {
|
||||
users.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: MentionGroup[] = [];
|
||||
if (users.length > 0) groups.push({ label: "Users", items: users });
|
||||
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionList — the popup rendered inside the editor
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,45 +119,93 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupItems(items);
|
||||
|
||||
// Build a flat index mapping: globalIndex → item
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-popover py-1 shadow-md min-w-[180px] max-h-[240px] overflow-y-auto">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
key={`${item.type}-${item.id}`}
|
||||
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
|
||||
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item.type === "all" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Users className="h-3 w-3" />
|
||||
</span>
|
||||
) : item.type === "issue" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Hash className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<ActorAvatar
|
||||
actorType={item.type}
|
||||
actorId={item.id}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
</button>
|
||||
{group.items.map((item) => {
|
||||
const idx = globalIndex++;
|
||||
return (
|
||||
<MentionRow
|
||||
key={`${item.type}-${item.id}`}
|
||||
item={item}
|
||||
selected={idx === selectedIndex}
|
||||
onSelect={() => selectItem(idx)}
|
||||
buttonRef={(el) => { itemRefs.current[idx] = el; }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionRow — single item in the list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MentionRow({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
buttonRef,
|
||||
}: {
|
||||
item: MentionItem;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
buttonRef: (el: HTMLButtonElement | null) => void;
|
||||
}) {
|
||||
if (item.type === "issue") {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{item.status && (
|
||||
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType={item.type === "all" ? "member" : item.type}
|
||||
actorId={item.id}
|
||||
size={20}
|
||||
/>
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.type === "agent" && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggestion config factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,7 +223,7 @@ export function createMentionSuggestion(): Omit<
|
||||
// Show "All members" option when query is empty or matches "all"
|
||||
const allItem: MentionItem[] =
|
||||
"all members".includes(q) || "all".includes(q)
|
||||
? [{ id: "all", label: "All members", type: "all" as const, description: "Notify all members" }]
|
||||
? [{ id: "all", label: "All members", type: "all" as const }]
|
||||
: [];
|
||||
|
||||
const memberItems: MentionItem[] = members
|
||||
@@ -156,7 +235,7 @@ export function createMentionSuggestion(): Omit<
|
||||
}));
|
||||
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => a.name.toLowerCase().includes(q))
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
const issueItems: MentionItem[] = issues
|
||||
@@ -170,6 +249,7 @@ export function createMentionSuggestion(): Omit<
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
status: i.status as IssueStatus,
|
||||
}));
|
||||
|
||||
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
79
apps/web/features/editor/extensions/mention-view.tsx
Normal file
79
apps/web/features/editor/extensions/mention-view.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MentionView — NodeView for rendering @mentions inline in the editor.
|
||||
*
|
||||
* Member/agent mentions: plain "@Name" text with .mention class styling.
|
||||
* Issue mentions: inline card with StatusIcon + identifier + title.
|
||||
*
|
||||
* Issue card sizing: must fit within the paragraph line box (14px * 1.625
|
||||
* = 22.75px). Card uses text-xs (12px) + py-0.5 + border ≈ 22px total.
|
||||
* vertical-align: middle is set on the [data-node-view-wrapper] in CSS
|
||||
* (not on the <a> tag) because the wrapper is the outermost inline element
|
||||
* that participates in line box calculation. Setting it on the inner <a>
|
||||
* had no effect since the wrapper was already positioned.
|
||||
*
|
||||
* Fallback: when issue is not in the Zustand store (deleted or other
|
||||
* workspace), the same card style is used with just the identifier from
|
||||
* fallbackLabel — no visual degradation to a plain text link.
|
||||
*/
|
||||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
|
||||
export function MentionView({ node }: NodeViewProps) {
|
||||
const { type, id, label } = node.attrs;
|
||||
|
||||
if (type === "issue") {
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline">
|
||||
<IssueMention issueId={id} fallbackLabel={label} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline">
|
||||
<span className="mention">@{label ?? id}</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueMention({
|
||||
issueId,
|
||||
fallbackLabel,
|
||||
}: {
|
||||
issueId: string;
|
||||
fallbackLabel?: string;
|
||||
}) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(`/issues/${issueId}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const cardClass =
|
||||
"issue-mention inline-flex items-center gap-1.5 rounded-md border mx-0.5 px-2 py-0.5 text-xs hover:bg-accent transition-colors cursor-pointer max-w-72";
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="font-medium text-muted-foreground shrink-0">{issue.identifier}</span>
|
||||
<span className="text-foreground truncate">{issue.title}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
15
apps/web/features/editor/extensions/submit-shortcut.ts
Normal file
15
apps/web/features/editor/extensions/submit-shortcut.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export function createSubmitExtension(onSubmit: () => void) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
11
apps/web/features/editor/index.ts
Normal file
11
apps/web/features/editor/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
ContentEditor,
|
||||
type ContentEditorProps,
|
||||
type ContentEditorRef,
|
||||
} from "./content-editor";
|
||||
export {
|
||||
TitleEditor,
|
||||
type TitleEditorProps,
|
||||
type TitleEditorRef,
|
||||
} from "./title-editor";
|
||||
export { copyMarkdown } from "./utils/clipboard";
|
||||
6
apps/web/features/editor/utils/clipboard.ts
Normal file
6
apps/web/features/editor/utils/clipboard.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copy markdown content to the clipboard.
|
||||
*/
|
||||
export async function copyMarkdown(markdown: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
}
|
||||
24
apps/web/features/editor/utils/preprocess.ts
Normal file
24
apps/web/features/editor/utils/preprocess.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { preprocessLinks } from "@/components/markdown/linkify";
|
||||
import { preprocessMentionShortcodes } from "@/components/markdown/mentions";
|
||||
|
||||
/**
|
||||
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
|
||||
*
|
||||
* This is the ONLY transform applied before @tiptap/markdown parses the content.
|
||||
* It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which
|
||||
* was deleted in the April 2026 refactor.
|
||||
*
|
||||
* Two string→string transforms on raw Markdown:
|
||||
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
|
||||
* (old serialization format in database, migrated on read)
|
||||
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
|
||||
*
|
||||
* After this, @tiptap/markdown's parse() handles everything else: headings, lists,
|
||||
* tables, code blocks, and our custom mention tokenizer ([@Name](mention://type/id)).
|
||||
*/
|
||||
export function preprocessMarkdown(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
const step1 = preprocessMentionShortcodes(markdown);
|
||||
const step2 = preprocessLinks(step1);
|
||||
return step2;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -72,6 +73,7 @@ export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load inbox");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useWSEvent } from "@/features/realtime";
|
||||
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
|
||||
import type { AgentTask } from "@/shared/types/agent";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { redactSecrets } from "../utils/redact";
|
||||
@@ -123,10 +124,10 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setItems(timeline);
|
||||
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(console.error);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [issueId]);
|
||||
@@ -194,18 +195,21 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// Pick up new tasks
|
||||
// Pick up new tasks — skip if we're already showing an active task to avoid
|
||||
// replacing its timeline mid-execution (per-issue serialization in the
|
||||
// backend prevents this race, but this is a defensive safeguard).
|
||||
useWSEvent(
|
||||
"task:dispatch",
|
||||
useCallback(() => {
|
||||
if (activeTask) return;
|
||||
api.getActiveTaskForIssue(issueId).then(({ task }) => {
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [issueId]),
|
||||
}).catch(console.error);
|
||||
}, [issueId, activeTask]),
|
||||
);
|
||||
|
||||
// Elapsed time
|
||||
@@ -235,7 +239,8 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setCancelling(true);
|
||||
try {
|
||||
await api.cancelTask(issueId, activeTask.id);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
|
||||
setCancelling(false);
|
||||
}
|
||||
}, [activeTask, issueId, cancelling]);
|
||||
@@ -318,7 +323,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]);
|
||||
|
||||
// Refresh when a task completes
|
||||
@@ -327,7 +332,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskCompletedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
@@ -336,7 +341,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskFailedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
@@ -346,7 +351,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskCancelledPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
@@ -379,7 +384,10 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
|
||||
if (items !== null) return; // already loaded
|
||||
api.listTaskMessages(task.id).then((msgs) => {
|
||||
setItems(buildTimeline(msgs));
|
||||
}).catch(() => setItems([]));
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
setItems([]);
|
||||
});
|
||||
}, [task.id, items]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function BatchActionToolbar() {
|
||||
toast.error("Failed to update issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export function BatchActionToolbar() {
|
||||
toast.error("Failed to delete issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteOpen(false);
|
||||
|
||||
@@ -13,6 +13,16 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
@@ -20,8 +30,7 @@ import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { Markdown } from "@/components/markdown/Markdown";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
@@ -40,6 +49,45 @@ interface CommentCardProps {
|
||||
onEdit: (commentId: string, content: string) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
/** ID of the comment to highlight (flash animation). */
|
||||
highlightedCommentId?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared delete confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteCommentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
hasReplies,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
hasReplies?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete comment</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{hasReplies
|
||||
? "This comment and all its replies will be permanently deleted. This cannot be undone."
|
||||
: "This comment will be permanently deleted. This cannot be undone."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,12 +111,13 @@ function CommentRow({
|
||||
}) {
|
||||
const { getActorName } = useActorName();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
@@ -136,7 +185,7 @@ function CommentRow({
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(entry.content ?? "");
|
||||
copyMarkdown(entry.content ?? "");
|
||||
toast.success("Copied");
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
@@ -150,7 +199,7 @@ function CommentRow({
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -158,6 +207,11 @@ function CommentRow({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteCommentDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
onConfirm={() => onDelete(entry.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -168,19 +222,19 @@ function CommentRow({
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
placeholder="Edit comment..."
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={(file) => uploadWithToast(file, { issueId })}
|
||||
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||
@@ -191,7 +245,7 @@ function CommentRow({
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
@@ -221,16 +275,18 @@ function CommentCard({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleReaction,
|
||||
highlightedCommentId,
|
||||
}: CommentCardProps) {
|
||||
const { getActorName } = useActorName();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
@@ -275,8 +331,10 @@ function CommentCard({
|
||||
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
||||
const reactions = entry.reactions ?? [];
|
||||
|
||||
const isHighlighted = highlightedCommentId === entry.id;
|
||||
|
||||
return (
|
||||
<Card className={`!py-0 !gap-0 overflow-hidden${isTemp ? " opacity-60" : ""}`}>
|
||||
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
{/* Header — always visible, acts as toggle */}
|
||||
<div className="px-4 py-3">
|
||||
@@ -328,7 +386,7 @@ function CommentCard({
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(entry.content ?? "");
|
||||
copyMarkdown(entry.content ?? "");
|
||||
toast.success("Copied");
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
@@ -342,7 +400,7 @@ function CommentCard({
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
|
||||
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -350,6 +408,12 @@ function CommentCard({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteCommentDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
onConfirm={() => onDelete(entry.id)}
|
||||
hasReplies
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -365,7 +429,7 @@ function CommentCard({
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
placeholder="Edit comment..."
|
||||
@@ -376,8 +440,7 @@ function CommentCard({
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={(file) => uploadWithToast(file, { issueId })}
|
||||
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||
@@ -388,7 +451,7 @@ function CommentCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
@@ -404,7 +467,7 @@ function CommentCard({
|
||||
|
||||
{/* Replies */}
|
||||
{allNestedReplies.map((reply) => (
|
||||
<div key={reply.id} className="border-t border-border/50 px-4">
|
||||
<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}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
@@ -13,16 +13,13 @@ interface CommentInputProps {
|
||||
}
|
||||
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
return await uploadWithToast(file, { issueId });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -30,10 +27,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
await onSubmit(content);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -43,7 +38,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
return (
|
||||
<div className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder="Leave a comment..."
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
@@ -55,11 +50,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) =>
|
||||
editorRef.current?.insertFile(result.filename, result.link, isImage)
|
||||
}
|
||||
disabled={uploading}
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
|
||||
@@ -7,17 +7,20 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -42,9 +45,9 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { RichTextEditor } from "@/components/common/rich-text-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { TitleEditor } from "@/components/common/title-editor";
|
||||
import { TitleEditor } from "@/features/editor";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
@@ -55,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, IssueDependency, IssueDependencyType, TimelineEntry } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
||||
import { CommentCard } from "./comment-card";
|
||||
@@ -123,11 +126,42 @@ function formatActivity(
|
||||
return "completed the task";
|
||||
case "task_failed":
|
||||
return "task failed";
|
||||
case "issue_relation_added": {
|
||||
const relType = details.relation_type ?? "";
|
||||
const identifier = details.related_issue_identifier ?? "?";
|
||||
const label = relationTypeLabel(relType);
|
||||
return `added relation: ${label} ${identifier}`;
|
||||
}
|
||||
case "issue_relation_removed": {
|
||||
const relType = details.relation_type ?? "";
|
||||
const identifier = details.related_issue_identifier ?? "?";
|
||||
const label = relationTypeLabel(relType);
|
||||
return `removed relation: ${label} ${identifier}`;
|
||||
}
|
||||
default:
|
||||
return entry.action ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function relationTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case "blocks":
|
||||
return "blocks";
|
||||
case "blocked_by":
|
||||
return "blocked by";
|
||||
case "related":
|
||||
return "related to";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function inverseRelType(type: string): string {
|
||||
if (type === "blocks") return "blocked_by";
|
||||
if (type === "blocked_by") return "blocks";
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property row
|
||||
@@ -160,13 +194,15 @@ interface IssueDetailProps {
|
||||
onDelete?: () => void;
|
||||
defaultSidebarOpen?: boolean;
|
||||
layoutId?: string;
|
||||
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
|
||||
highlightCommentId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout" }: IssueDetailProps) {
|
||||
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
|
||||
const id = issueId;
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -190,7 +226,16 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||
const [relationsOpen, setRelationsOpen] = useState(true);
|
||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||
const [dependencies, setDependencies] = useState<IssueDependency[]>([]);
|
||||
const [addRelOpen, setAddRelOpen] = useState(false);
|
||||
const [relSearch, setRelSearch] = useState("");
|
||||
const [relType, setRelType] = useState<IssueDependencyType>("related");
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const didHighlightRef = useRef<string | null>(null);
|
||||
|
||||
// Single source of truth: read issue directly from global store
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
|
||||
@@ -208,27 +253,89 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
.then((iss) => {
|
||||
useIssueStore.getState().addIssue(iss);
|
||||
})
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load issue");
|
||||
})
|
||||
.finally(() => setIssueLoading(false));
|
||||
}, [id, !!issue]);
|
||||
|
||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||
const {
|
||||
timeline, submitting, submitComment, submitReply,
|
||||
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
|
||||
editComment, deleteComment, toggleReaction: handleToggleReaction,
|
||||
} = useIssueTimeline(id, user?.id);
|
||||
|
||||
const {
|
||||
reactions: issueReactions,
|
||||
reactions: issueReactions, loading: reactionsLoading,
|
||||
toggleReaction: handleToggleIssueReaction,
|
||||
} = useIssueReactions(id, user?.id);
|
||||
|
||||
const {
|
||||
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
} = useIssueSubscribers(id, user?.id);
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// Fetch issue dependencies
|
||||
const fetchDependencies = useCallback(() => {
|
||||
api.listDependencies(id).then(setDependencies).catch(() => {});
|
||||
}, [id]);
|
||||
useEffect(() => { fetchDependencies(); }, [fetchDependencies]);
|
||||
|
||||
const handleAddDependency = useCallback(async (targetIssueId: string, type: IssueDependencyType) => {
|
||||
try {
|
||||
await api.createDependency(id, targetIssueId, type);
|
||||
fetchDependencies();
|
||||
setAddRelOpen(false);
|
||||
setRelSearch("");
|
||||
} catch {
|
||||
toast.error("Failed to add relation");
|
||||
}
|
||||
}, [id, fetchDependencies]);
|
||||
|
||||
const handleRemoveDependency = useCallback(async (depId: string) => {
|
||||
try {
|
||||
await api.deleteDependency(id, depId);
|
||||
fetchDependencies();
|
||||
} catch {
|
||||
toast.error("Failed to remove relation");
|
||||
}
|
||||
}, [id, fetchDependencies]);
|
||||
|
||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
||||
useEffect(() => {
|
||||
if (!highlightCommentId || timeline.length === 0) return;
|
||||
if (didHighlightRef.current === highlightCommentId) return;
|
||||
const el = document.getElementById(`comment-${highlightCommentId}`);
|
||||
if (el) {
|
||||
didHighlightRef.current = highlightCommentId;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setHighlightedId(highlightCommentId);
|
||||
const timer = setTimeout(() => setHighlightedId(null), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
}, [highlightCommentId, timeline.length]);
|
||||
|
||||
// Track scroll position for jump-to-bottom button
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const onScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
// Issue field updates — write directly to the global store (single source of truth)
|
||||
const handleUpdateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
@@ -243,7 +350,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
[issue, id],
|
||||
);
|
||||
|
||||
const descEditorRef = useRef<import("@/components/common/rich-text-editor").RichTextEditorRef>(null);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||
[uploadWithToast, id],
|
||||
@@ -265,8 +372,51 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Content skeleton */}
|
||||
<div className="flex-1 p-8 space-y-6">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
<Skeleton className="h-px w-full" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sidebar skeleton */}
|
||||
<div className="w-64 border-l p-4 space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-px w-full" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -541,7 +691,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
|
||||
{/* Content — scrollable */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div ref={scrollContainerRef} className="relative flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-4xl px-8 py-8">
|
||||
<TitleEditor
|
||||
key={`title-${id}`}
|
||||
@@ -554,7 +704,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}}
|
||||
/>
|
||||
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
key={id}
|
||||
defaultValue={issue.description || ""}
|
||||
@@ -566,15 +716,21 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
/>
|
||||
{reactionsLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-7 w-14 rounded-full" />
|
||||
<Skeleton className="h-7 w-14 rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
/>
|
||||
)}
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleDescriptionUpload}
|
||||
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -587,6 +743,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<h2 className="text-base font-semibold">Activity</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{subscribersLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="flex -space-x-1">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<button
|
||||
onClick={handleToggleSubscribe}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -664,6 +829,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -682,7 +848,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
{/* Timeline entries */}
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{(() => {
|
||||
{timelineLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 px-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (() => {
|
||||
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
|
||||
const repliesByParent = new Map<string, TimelineEntry[]>();
|
||||
for (const e of timeline) {
|
||||
@@ -733,17 +911,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
if (group.type === "comment") {
|
||||
const entry = group.entries[0]!;
|
||||
return (
|
||||
<CommentCard
|
||||
key={entry.id}
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
onReply={submitReply}
|
||||
onEdit={editComment}
|
||||
onDelete={deleteComment}
|
||||
onToggleReaction={handleToggleReaction}
|
||||
/>
|
||||
<div key={entry.id} id={`comment-${entry.id}`}>
|
||||
<CommentCard
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
onReply={submitReply}
|
||||
onEdit={editComment}
|
||||
onDelete={deleteComment}
|
||||
onToggleReaction={handleToggleReaction}
|
||||
highlightedCommentId={highlightedId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -754,6 +934,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const isStatusChange = entry.action === "status_changed";
|
||||
const isPriorityChange = entry.action === "priority_changed";
|
||||
const isDueDateChange = entry.action === "due_date_changed";
|
||||
const isRelationChange = entry.action === "issue_relation_added" || entry.action === "issue_relation_removed";
|
||||
|
||||
let leadIcon: React.ReactNode;
|
||||
if (isStatusChange && details.to) {
|
||||
@@ -762,6 +943,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
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 if (isRelationChange) {
|
||||
leadIcon = <Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
} else {
|
||||
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
|
||||
}
|
||||
@@ -802,6 +985,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Jump to bottom button */}
|
||||
{showScrollBottom && (
|
||||
<div className="sticky bottom-4 flex justify-center pointer-events-none">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="pointer-events-auto shadow-md"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="mr-1 h-3.5 w-3.5" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -890,6 +1087,102 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Relations section */}
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<button
|
||||
className={`flex flex-1 items-center gap-1 text-xs font-medium transition-colors ${relationsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setRelationsOpen(!relationsOpen)}
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${relationsOpen ? "rotate-90" : ""}`} />
|
||||
Relations
|
||||
{dependencies.length > 0 && <span className="text-muted-foreground ml-1">({dependencies.length})</span>}
|
||||
</button>
|
||||
<Popover open={addRelOpen} onOpenChange={setAddRelOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(["related", "blocks", "blocked_by"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-2 py-0.5 text-xs rounded-full transition-colors ${relType === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setRelType(t)}
|
||||
>
|
||||
{relationTypeLabel(t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search issues..." value={relSearch} onValueChange={setRelSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No issues found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allIssues
|
||||
.filter((i) => i.id !== id)
|
||||
.filter((i) => !dependencies.some(
|
||||
(d) => (d.issue_id === id ? d.depends_on_issue_id : d.issue_id) === i.id
|
||||
))
|
||||
.slice(0, 20)
|
||||
.map((i) => (
|
||||
<CommandItem
|
||||
key={i.id}
|
||||
value={`${i.identifier} ${i.title}`}
|
||||
onSelect={() => handleAddDependency(i.id, relType)}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 mr-1.5">{i.identifier}</span>
|
||||
<span className="truncate">{i.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{relationsOpen && dependencies.length > 0 && (
|
||||
<div className="space-y-1 pl-2">
|
||||
{dependencies.map((dep) => {
|
||||
// Determine which side is the "other" issue
|
||||
const isSource = dep.issue_id === id;
|
||||
const otherIdentifier = isSource ? dep.depends_on_issue_identifier : dep.issue_identifier;
|
||||
const otherTitle = isSource ? dep.depends_on_issue_title : dep.issue_title;
|
||||
const otherIssueId = isSource ? dep.depends_on_issue_id : dep.issue_id;
|
||||
// Show the relation type from this issue's perspective
|
||||
const displayType = isSource ? dep.type : inverseRelType(dep.type);
|
||||
|
||||
return (
|
||||
<div key={dep.id} className="group flex items-center gap-1.5 text-xs rounded-md px-2 -mx-2 min-h-7 hover:bg-accent/50 transition-colors">
|
||||
<Link2 className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-muted-foreground">{relationTypeLabel(displayType)}</span>
|
||||
<Link href={`/issues/${otherIssueId}`} className="flex items-center gap-1 min-w-0 hover:underline">
|
||||
<span className="shrink-0 text-muted-foreground">{otherIdentifier}</span>
|
||||
<span className="truncate">{otherTitle}</span>
|
||||
</Link>
|
||||
<button
|
||||
className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent transition-all text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveDependency(dep.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{relationsOpen && dependencies.length === 0 && (
|
||||
<div className="pl-2 text-xs text-muted-foreground">No relations</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, ListTodo } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
@@ -84,7 +84,7 @@ export function IssuesPage() {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
});
|
||||
},
|
||||
[]
|
||||
@@ -131,19 +131,27 @@ export function IssuesPage() {
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<ViewStoreProvider store={useIssueViewStore}>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{scopedIssues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">No issues yet</p>
|
||||
<p className="text-xs">Create an issue to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function AssigneePicker({
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(query),
|
||||
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
@@ -33,13 +33,12 @@ function ReplyInput({
|
||||
onSubmit,
|
||||
size = "default",
|
||||
}: ReplyInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current;
|
||||
@@ -53,9 +52,7 @@ function ReplyInput({
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
return await uploadWithToast(file, { issueId });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -63,10 +60,8 @@ function ReplyInput({
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
await onSubmit(content);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -92,7 +87,7 @@ function ReplyInput({
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
|
||||
<div ref={measureRef}>
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
@@ -105,11 +100,7 @@ function ReplyInput({
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) =>
|
||||
editorRef.current?.insertFile(result.filename, result.link, isImage)
|
||||
}
|
||||
disabled={uploading}
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
IssueReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueReactions(issueId: string, userId?: string) {
|
||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
||||
@@ -21,7 +21,10 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||
api
|
||||
.getIssue(issueId)
|
||||
.then((iss) => setReactions(iss.reactions ?? []))
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load reactions");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
||||
@@ -21,7 +21,10 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
api
|
||||
.listIssueSubscribers(issueId)
|
||||
.then((subs) => setSubscribers(subs))
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load subscribers");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -41,7 +41,10 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
api
|
||||
.listTimeline(issueId)
|
||||
.then((entries) => setTimeline(entries))
|
||||
.catch(console.error)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load activity");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -34,6 +35,7 @@ export const useIssueStore = create<IssueState>((set, get) => ({
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load issues");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { TitleEditor } from "@/components/common/title-editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { TitleEditor } from "@/features/editor";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
@@ -77,7 +77,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const descEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || draft.status);
|
||||
const [priority, setPriority] = useState<IssuePriority>(draft.priority);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -231,7 +231,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
|
||||
{/* Description — takes remaining space */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<RichTextEditor
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue={draft.description}
|
||||
placeholder="Add description..."
|
||||
@@ -419,8 +419,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<FileUploadButton
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, ListTodo } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
@@ -124,7 +124,7 @@ export function MyIssuesPage() {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
}).catch(console.error);
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -171,19 +171,27 @@ export function MyIssuesPage() {
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<ViewStoreProvider store={myIssuesViewStore}>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={myIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{myIssues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">No issues assigned to you</p>
|
||||
<p className="text-xs">Issues you create or are assigned to will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={myIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
WorkspaceDeletedPayload,
|
||||
MemberRemovedPayload,
|
||||
IssueUpdatedPayload,
|
||||
IssueCreatedPayload,
|
||||
IssueDeletedPayload,
|
||||
InboxNewPayload,
|
||||
} from "@/shared/types";
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
@@ -34,8 +37,12 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
// Event types handled by specific handlers below — skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||
]);
|
||||
|
||||
const refreshMap: Record<string, () => void> = {
|
||||
issue: () => void useIssueStore.getState().fetch(),
|
||||
inbox: () => void useInboxStore.getState().fetch(),
|
||||
agent: () => void useWorkspaceStore.getState().refreshAgents(),
|
||||
member: () => void useWorkspaceStore.getState().refreshMembers(),
|
||||
@@ -74,21 +81,40 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
logger.debug("skipping self-event", msg.type);
|
||||
return;
|
||||
}
|
||||
if (specificEvents.has(msg.type)) return;
|
||||
const prefix = msg.type.split(":")[0] ?? "";
|
||||
const refresh = refreshMap[prefix];
|
||||
if (refresh) debouncedRefresh(prefix, refresh);
|
||||
});
|
||||
|
||||
// --- Side-effect handlers (toast, navigation, cross-store sync) ---
|
||||
// --- Specific event handlers (granular updates, no full refetch) ---
|
||||
|
||||
// Keep inbox issue_status in sync when issues change
|
||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
if (issue?.id && issue?.status) {
|
||||
if (!issue?.id) return;
|
||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
||||
if (issue.status) {
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (issue) useIssueStore.getState().addIssue(issue);
|
||||
});
|
||||
|
||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (item) useInboxStore.getState().addItem(item);
|
||||
});
|
||||
|
||||
// --- Side-effect handlers (toast, navigation) ---
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
const currentWs = useWorkspaceStore.getState().workspace;
|
||||
@@ -123,6 +149,9 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
unsubIssueCreated();
|
||||
unsubIssueDeleted();
|
||||
unsubInboxNew();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
@@ -145,8 +174,8 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||
useWorkspaceStore.getState().refreshMembers(),
|
||||
useWorkspaceStore.getState().refreshSkills(),
|
||||
]);
|
||||
} catch {
|
||||
// Silently fail; next reconnect will retry
|
||||
} catch (e) {
|
||||
logger.error("reconnect refetch failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,24 @@ import type { AgentRuntime } from "@/shared/types";
|
||||
import { formatLastSeen } from "../utils";
|
||||
import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared";
|
||||
import { PingSection } from "./ping-section";
|
||||
import { UpdateSection } from "./update-section";
|
||||
import { UsageSection } from "./usage-section";
|
||||
|
||||
function getCliVersion(metadata: Record<string, unknown>): string | null {
|
||||
if (
|
||||
metadata &&
|
||||
typeof metadata.cli_version === "string" &&
|
||||
metadata.cli_version
|
||||
) {
|
||||
return metadata.cli_version;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
|
||||
const cliVersion =
|
||||
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
@@ -43,6 +58,20 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CLI Version & Update */}
|
||||
{runtime.runtime_mode === "local" && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-3">
|
||||
CLI Version
|
||||
</h3>
|
||||
<UpdateSection
|
||||
runtimeId={runtime.id}
|
||||
currentVersion={cliVersion}
|
||||
isOnline={runtime.status === "online"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection Test */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-3">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
@@ -44,8 +45,36 @@ export default function RuntimesPage() {
|
||||
|
||||
if (isLoading || fetching) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* List skeleton */}
|
||||
<div className="w-72 border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Detail skeleton */}
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
230
apps/web/features/runtimes/components/update-section.tsx
Normal file
230
apps/web/features/runtimes/components/update-section.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ArrowUpCircle,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/shared/api";
|
||||
import type { RuntimeUpdateStatus } from "@/shared/types";
|
||||
|
||||
const GITHUB_RELEASES_URL =
|
||||
"https://api.github.com/repos/multica-ai/multica/releases/latest";
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
let cachedLatestVersion: string | null = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
async function fetchLatestVersion(): Promise<string | null> {
|
||||
if (cachedLatestVersion && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||
return cachedLatestVersion;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(GITHUB_RELEASES_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
cachedLatestVersion = data.tag_name ?? null;
|
||||
cachedAt = Date.now();
|
||||
return cachedLatestVersion;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripV(v: string): string {
|
||||
return v.replace(/^v/, "");
|
||||
}
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const l = stripV(latest).split(".").map(Number);
|
||||
const c = stripV(current).split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
const lv = l[i] ?? 0;
|
||||
const cv = c[i] ?? 0;
|
||||
if (lv > cv) return true;
|
||||
if (lv < cv) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
RuntimeUpdateStatus,
|
||||
{ label: string; icon: typeof Loader2; color: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: "Waiting for daemon...",
|
||||
icon: Loader2,
|
||||
color: "text-muted-foreground",
|
||||
},
|
||||
running: {
|
||||
label: "Updating...",
|
||||
icon: Loader2,
|
||||
color: "text-info",
|
||||
},
|
||||
completed: {
|
||||
label: "Update complete. Daemon is restarting...",
|
||||
icon: CheckCircle2,
|
||||
color: "text-success",
|
||||
},
|
||||
failed: { label: "Update failed", icon: XCircle, color: "text-destructive" },
|
||||
timeout: { label: "Timeout", icon: XCircle, color: "text-warning" },
|
||||
};
|
||||
|
||||
interface UpdateSectionProps {
|
||||
runtimeId: string;
|
||||
currentVersion: string | null;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export function UpdateSection({
|
||||
runtimeId,
|
||||
currentVersion,
|
||||
isOnline,
|
||||
}: UpdateSectionProps) {
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<RuntimeUpdateStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [output, setOutput] = useState("");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
// Fetch latest version on mount.
|
||||
useEffect(() => {
|
||||
fetchLatestVersion().then(setLatestVersion);
|
||||
}, []);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!latestVersion) return;
|
||||
cleanup();
|
||||
setUpdating(true);
|
||||
setStatus("pending");
|
||||
setError("");
|
||||
setOutput("");
|
||||
|
||||
try {
|
||||
const update = await api.initiateUpdate(runtimeId, latestVersion);
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await api.getUpdateResult(runtimeId, update.id);
|
||||
setStatus(result.status as RuntimeUpdateStatus);
|
||||
|
||||
if (result.status === "completed") {
|
||||
setOutput(result.output ?? "");
|
||||
setUpdating(false);
|
||||
cleanup();
|
||||
// Auto-clear status after a few seconds so the UI
|
||||
// refreshes to show the new version from the re-fetched runtime data.
|
||||
setTimeout(() => setStatus(null), 5000);
|
||||
} else if (
|
||||
result.status === "failed" ||
|
||||
result.status === "timeout"
|
||||
) {
|
||||
setError(result.error ?? "Unknown error");
|
||||
setUpdating(false);
|
||||
cleanup();
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
}, 2000);
|
||||
} catch {
|
||||
setStatus("failed");
|
||||
setError("Failed to initiate update");
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasUpdate =
|
||||
currentVersion &&
|
||||
latestVersion &&
|
||||
isNewer(latestVersion, currentVersion);
|
||||
|
||||
const config = status ? statusConfig[status] : null;
|
||||
const Icon = config?.icon;
|
||||
const isActive = status === "pending" || status === "running";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">CLI Version:</span>
|
||||
<span className="text-xs font-mono">
|
||||
{currentVersion ?? "unknown"}
|
||||
</span>
|
||||
|
||||
{!hasUpdate && currentVersion && latestVersion && !status && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-success">
|
||||
<Check className="h-3 w-3" />
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasUpdate && !status && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<span className="text-xs font-mono text-info">
|
||||
{latestVersion}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">available</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasUpdate && isOnline && !status && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
>
|
||||
<ArrowUpCircle className="h-3 w-3" />
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{config && Icon && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs ${config.color}`}
|
||||
>
|
||||
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
|
||||
{config.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "completed" && output && (
|
||||
<div className="rounded-lg border bg-success/5 px-3 py-2">
|
||||
<p className="text-xs text-success">{output}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "failed" || status === "timeout") && error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
{status === "failed" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mt-1"
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { RuntimeUsage } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { formatTokens, estimateCost, aggregateByDate } from "../utils";
|
||||
@@ -38,7 +39,22 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">Loading usage...</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-12 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-64 rounded-lg" />
|
||||
<Skeleton className="h-64 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
@@ -352,6 +354,7 @@ function SkillDetail({
|
||||
);
|
||||
const [selectedPath, setSelectedPath] = useState(SKILL_MD);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showAddFile, setShowAddFile] = useState(false);
|
||||
|
||||
@@ -365,10 +368,13 @@ function SkillDetail({
|
||||
// Fetch full skill (with files) on selection change
|
||||
useEffect(() => {
|
||||
setSelectedPath(SKILL_MD);
|
||||
setLoadingFiles(true);
|
||||
api.getSkill(skill.id).then((full) => {
|
||||
useWorkspaceStore.getState().upsertSkill(full);
|
||||
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
||||
});
|
||||
}).catch((e) => {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
|
||||
}).finally(() => setLoadingFiles(false));
|
||||
}, [skill.id]);
|
||||
|
||||
// Build the virtual file map
|
||||
@@ -392,6 +398,8 @@ function SkillDetail({
|
||||
content,
|
||||
files: files.filter((f) => f.path.trim()),
|
||||
});
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -514,22 +522,40 @@ function SkillDetail({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<FileTree
|
||||
filePaths={filePaths}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
{loadingFiles ? (
|
||||
<div className="p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : (
|
||||
<FileTree
|
||||
filePaths={filePaths}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File viewer */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{loadingFiles ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<FileViewer
|
||||
key={selectedPath}
|
||||
path={selectedPath}
|
||||
content={selectedContent}
|
||||
onChange={handleFileContentChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -604,34 +630,83 @@ export default function SkillsPage() {
|
||||
const skill = await api.createSkill(data);
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
toast.success("Skill created");
|
||||
};
|
||||
|
||||
const handleImport = async (url: string) => {
|
||||
const skill = await api.importSkill({ url });
|
||||
upsertSkill(skill);
|
||||
setSelectedId(skill.id);
|
||||
toast.success("Skill imported");
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
|
||||
const updated = await api.updateSkill(id, data);
|
||||
upsertSkill(updated);
|
||||
try {
|
||||
const updated = await api.updateSkill(id, data);
|
||||
upsertSkill(updated);
|
||||
toast.success("Skill saved");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save skill");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api.deleteSkill(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = skills.filter((s) => s.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
try {
|
||||
await api.deleteSkill(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = skills.filter((s) => s.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
}
|
||||
removeSkill(id);
|
||||
toast.success("Skill deleted");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
|
||||
}
|
||||
removeSkill(id);
|
||||
};
|
||||
|
||||
const selected = skills.find((s) => s.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* List skeleton */}
|
||||
<div className="w-72 border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Detail skeleton */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-8 w-56" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-48 border-r p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -76,11 +77,19 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id }),
|
||||
api.listMembers(nextWorkspace.id).catch((e) => {
|
||||
logger.error("failed to load members", e);
|
||||
toast.error("Failed to load members");
|
||||
return [] as MemberWithUser[];
|
||||
}),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
|
||||
logger.error("failed to load agents", e);
|
||||
toast.error("Failed to load agents");
|
||||
return [] as Agent[];
|
||||
}),
|
||||
api.listSkills().catch(() => [] as Skill[]),
|
||||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
useIssueStore.getState().fetch().catch(() => {}),
|
||||
useInboxStore.getState().fetch().catch(() => {}),
|
||||
]);
|
||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
||||
@@ -113,16 +122,27 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
refreshWorkspaces: async () => {
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
try {
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh workspaces", e);
|
||||
toast.error("Failed to refresh workspaces");
|
||||
return get().workspaces;
|
||||
}
|
||||
},
|
||||
|
||||
refreshMembers: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
const members = await api.listMembers(workspace.id);
|
||||
set({ members });
|
||||
try {
|
||||
const members = await api.listMembers(workspace.id);
|
||||
set({ members });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh members", e);
|
||||
toast.error("Failed to refresh members");
|
||||
}
|
||||
},
|
||||
|
||||
updateAgent: (id, updates) =>
|
||||
@@ -133,23 +153,33 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
refreshAgents: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id });
|
||||
set({ agents });
|
||||
try {
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
|
||||
set({ agents });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh agents", e);
|
||||
toast.error("Failed to refresh agents");
|
||||
}
|
||||
},
|
||||
|
||||
refreshSkills: async () => {
|
||||
const { workspace, skills: existing } = get();
|
||||
if (!workspace) return;
|
||||
const fetched = await api.listSkills();
|
||||
// listSkills doesn't include files — preserve files from existing entries
|
||||
const filesById = new Map(
|
||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
||||
);
|
||||
const merged = fetched.map((s) => ({
|
||||
...s,
|
||||
files: s.files ?? filesById.get(s.id) ?? [],
|
||||
}));
|
||||
set({ skills: merged });
|
||||
try {
|
||||
const fetched = await api.listSkills();
|
||||
// listSkills doesn't include files — preserve files from existing entries
|
||||
const filesById = new Map(
|
||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
||||
);
|
||||
const merged = fetched.map((s) => ({
|
||||
...s,
|
||||
files: s.files ?? filesById.get(s.id) ?? [],
|
||||
}));
|
||||
set({ skills: merged });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh skills", e);
|
||||
toast.error("Failed to refresh skills");
|
||||
}
|
||||
},
|
||||
|
||||
upsertSkill: (skill) => {
|
||||
|
||||
@@ -18,16 +18,21 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@tiptap/extension-code-block-lowlight": "3.20.5",
|
||||
"@tiptap/extension-image": "^3.20.5",
|
||||
"@tiptap/extension-link": "^3.20.5",
|
||||
"@tiptap/extension-mention": "^3.20.5",
|
||||
"@tiptap/extension-placeholder": "^3.20.5",
|
||||
"@tiptap/extension-typography": "^3.20.5",
|
||||
"@tiptap/markdown": "^3.20.5",
|
||||
"@tiptap/pm": "^3.20.5",
|
||||
"@tiptap/react": "^3.20.5",
|
||||
"@tiptap/starter-kit": "^3.20.5",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||
"@tiptap/extension-image": "^3.22.1",
|
||||
"@tiptap/extension-link": "^3.22.1",
|
||||
"@tiptap/extension-mention": "^3.22.1",
|
||||
"@tiptap/suggestion": "^3.22.1",
|
||||
"@tiptap/extension-placeholder": "^3.22.1",
|
||||
"@tiptap/extension-table": "^3.22.1",
|
||||
"@tiptap/extension-table-cell": "^3.22.1",
|
||||
"@tiptap/extension-table-header": "^3.22.1",
|
||||
"@tiptap/extension-table-row": "^3.22.1",
|
||||
"@tiptap/extension-typography": "^3.22.1",
|
||||
"@tiptap/markdown": "^3.22.1",
|
||||
"@tiptap/pm": "^3.22.1",
|
||||
"@tiptap/react": "^3.22.1",
|
||||
"@tiptap/starter-kit": "^3.22.1",
|
||||
"@types/linkify-it": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -31,9 +31,12 @@ import type {
|
||||
RuntimeUsage,
|
||||
RuntimeHourlyActivity,
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
TimelineEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
IssueDependency,
|
||||
IssueDependencyType,
|
||||
} from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
|
||||
@@ -225,6 +228,22 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/timeline`);
|
||||
}
|
||||
|
||||
// Issue dependencies
|
||||
async listDependencies(issueId: string): Promise<IssueDependency[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/dependencies`);
|
||||
}
|
||||
|
||||
async createDependency(issueId: string, dependsOnIssueId: string, type: IssueDependencyType): Promise<IssueDependency> {
|
||||
return this.fetch(`/api/issues/${issueId}/dependencies`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ depends_on_issue_id: dependsOnIssueId, type }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDependency(issueId: string, depId: string): Promise<void> {
|
||||
await this.fetch(`/api/issues/${issueId}/dependencies/${depId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
@@ -290,10 +309,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// Agents
|
||||
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
|
||||
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.include_archived) search.set("include_archived", "true");
|
||||
return this.fetch(`/api/agents?${search}`);
|
||||
}
|
||||
|
||||
@@ -315,8 +335,12 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<void> {
|
||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
||||
async archiveAgent(id: string): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
|
||||
}
|
||||
|
||||
async restoreAgent(id: string): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
||||
@@ -344,6 +368,23 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/ping/${pingId}`);
|
||||
}
|
||||
|
||||
async initiateUpdate(
|
||||
runtimeId: string,
|
||||
targetVersion: string,
|
||||
): Promise<RuntimeUpdate> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/update`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target_version: targetVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
async getUpdateResult(
|
||||
runtimeId: string,
|
||||
updateId: string,
|
||||
): Promise<RuntimeUpdate> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
|
||||
}
|
||||
|
||||
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
@@ -5,31 +5,7 @@ import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import type { Attachment } from "@/shared/types";
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"application/zip",
|
||||
]);
|
||||
|
||||
function isAllowedType(type: string): boolean {
|
||||
// Empty MIME type (browser couldn't determine) — let the server sniff and decide.
|
||||
if (!type) return true;
|
||||
const mediaType = type.split(";")[0] ?? "";
|
||||
return ALLOWED_TYPES.has(mediaType.trim().toLowerCase());
|
||||
}
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
export interface UploadResult {
|
||||
id: string;
|
||||
@@ -48,10 +24,7 @@ export function useFileUpload() {
|
||||
const upload = useCallback(
|
||||
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error("File exceeds 10 MB limit");
|
||||
}
|
||||
if (!isAllowedType(file.type)) {
|
||||
throw new Error(`File type not allowed: ${file.type}`);
|
||||
throw new Error("File exceeds 100 MB limit");
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface Agent {
|
||||
triggers: AgentTrigger[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
@@ -174,3 +176,21 @@ export interface RuntimeHourlyActivity {
|
||||
hour: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type RuntimeUpdateStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "timeout";
|
||||
|
||||
export interface RuntimeUpdate {
|
||||
id: string;
|
||||
runtime_id: string;
|
||||
status: RuntimeUpdateStatus;
|
||||
target_version: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ export type WSEventType =
|
||||
| "comment:deleted"
|
||||
| "agent:status"
|
||||
| "agent:created"
|
||||
| "agent:deleted"
|
||||
| "agent:archived"
|
||||
| "agent:restored"
|
||||
| "task:dispatch"
|
||||
| "task:progress"
|
||||
| "task:completed"
|
||||
@@ -71,9 +72,12 @@ export interface AgentCreatedPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface AgentDeletedPayload {
|
||||
agent_id: string;
|
||||
workspace_id: string;
|
||||
export interface AgentArchivedPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface AgentRestoredPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface InboxNewPayload {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue";
|
||||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction, IssueDependency, IssueDependencyType } from "./issue";
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
@@ -21,6 +21,8 @@ export type {
|
||||
RuntimeHourlyActivity,
|
||||
RuntimePing,
|
||||
RuntimePingStatus,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user