mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
107 Commits
agent/lamb
...
fix/self-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8a2040f5 | ||
|
|
6bbe059055 | ||
|
|
cf70860a0b | ||
|
|
9f350e312d | ||
|
|
08c3513eef | ||
|
|
817e69a9eb | ||
|
|
f94b0100cd | ||
|
|
287a9eb546 | ||
|
|
45dad23074 | ||
|
|
762e64d469 | ||
|
|
f1415e9622 | ||
|
|
8030f1adbc | ||
|
|
eacf33299a | ||
|
|
cf012b2706 | ||
|
|
2cbebfc568 | ||
|
|
100146c49e | ||
|
|
de982f3a4e | ||
|
|
53cb01cc91 | ||
|
|
afa711b442 | ||
|
|
8d6e5f2bcc | ||
|
|
c460206846 | ||
|
|
70e4f44860 | ||
|
|
4b10c9354a | ||
|
|
d88fe2608e | ||
|
|
c79cfaf330 | ||
|
|
60c5848794 | ||
|
|
642c6ae5ee | ||
|
|
1163f684fb | ||
|
|
ff1d348274 | ||
|
|
b4b69f89f6 | ||
|
|
a3c6f07668 | ||
|
|
b2649fb47f | ||
|
|
c2a5ed73e8 | ||
|
|
f0c0a64ddd | ||
|
|
2ecddc8fc8 | ||
|
|
2a2e6f4746 | ||
|
|
6538496ee4 | ||
|
|
69ef002bbb | ||
|
|
7dad45d444 | ||
|
|
7ade4b432d | ||
|
|
cbb2cf0c6c | ||
|
|
d94b704a71 | ||
|
|
76ba9cfb0b | ||
|
|
40aa23a528 | ||
|
|
d3f7570177 | ||
|
|
34e452776b | ||
|
|
2551aa53ef | ||
|
|
d779cbd183 | ||
|
|
10b6afc1ec | ||
|
|
4f58f0c8eb | ||
|
|
0399e387f8 | ||
|
|
a744cd4f45 | ||
|
|
bfa9bec8c4 | ||
|
|
bf71802451 | ||
|
|
09e6190400 | ||
|
|
0798b5f8bb | ||
|
|
e568896357 | ||
|
|
8748557c7b | ||
|
|
7f0c23a6ba | ||
|
|
e6767d2ba3 | ||
|
|
1ceb75e218 | ||
|
|
9138c05993 | ||
|
|
091ed7370a | ||
|
|
35557c0b11 | ||
|
|
03ad47200b | ||
|
|
93b754de53 | ||
|
|
609d2e06ae | ||
|
|
7c436c0dcb | ||
|
|
55ae78b902 | ||
|
|
cc00fda513 | ||
|
|
04e571b02f | ||
|
|
c62bd0ca12 | ||
|
|
51c7dbbeee | ||
|
|
46d745cb60 | ||
|
|
0a998d1cef | ||
|
|
a366984014 | ||
|
|
9ba9ea66f8 | ||
|
|
2be6fdae90 | ||
|
|
653c0adeee | ||
|
|
4458753102 | ||
|
|
3c0ed0f732 | ||
|
|
999d0728c5 | ||
|
|
b6a69c113e | ||
|
|
7995f7368f | ||
|
|
ed1a1dc6b1 | ||
|
|
97755ae45d | ||
|
|
7a896d3852 | ||
|
|
da63165cdc | ||
|
|
013584ef80 | ||
|
|
bb4944bae2 | ||
|
|
42e392c727 | ||
|
|
158a100779 | ||
|
|
e178682acd | ||
|
|
8779db976c | ||
|
|
eba68c15fd | ||
|
|
345cb984a9 | ||
|
|
f3355049bc | ||
|
|
dca86acc69 | ||
|
|
c71525e198 | ||
|
|
977dc6479d | ||
|
|
a97bd3da0b | ||
|
|
9dfe119f47 | ||
|
|
418049856f | ||
|
|
9170b01739 | ||
|
|
a0d43ca31a | ||
|
|
a73a9d4036 | ||
|
|
4165401d16 |
@@ -7,8 +7,10 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
APP_ENV=
|
||||
TASK_DOMAIN=localhost
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_SERVER_URL=http://localhost:8080
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
MULTICA_DAEMON_CONFIG=
|
||||
MULTICA_WORKSPACE_ID=
|
||||
@@ -60,4 +62,4 @@ NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
||||
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
# REMOTE_API_URL=https://multica-api.copilothub.ai
|
||||
REMOTE_API_URL=http://localhost:8080
|
||||
|
||||
12
.github/PULL_REQUEST_TEMPLATE.md
vendored
12
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -35,11 +35,13 @@ Closes #
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
|
||||
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
|
||||
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
|
||||
- [ ] Changes follow existing code patterns and conventions
|
||||
- [ ] No unrelated changes included
|
||||
- [ ] I have included a thinking path that traces from project context to this change
|
||||
- [ ] I have run tests locally and they pass
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -12,10 +12,17 @@ build
|
||||
bin
|
||||
dist-electron
|
||||
*.tsbuildinfo
|
||||
# ...except electron-builder's source resources dir, which holds tracked
|
||||
# config files (entitlements, icons) — not build output.
|
||||
!apps/desktop/build/
|
||||
!apps/desktop/build/**
|
||||
|
||||
# env
|
||||
.env*
|
||||
!.env.example
|
||||
# Desktop production config is public (backend URL, etc.) — track it so
|
||||
# `pnpm package` produces a release-ready build without extra setup.
|
||||
!apps/desktop/.env.production
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
|
||||
15
Caddyfile
Normal file
15
Caddyfile
Normal file
@@ -0,0 +1,15 @@
|
||||
{$TASK_DOMAIN} {
|
||||
@next_static path /_next/static/*
|
||||
header @next_static Cache-Control "public, max-age=31536000, immutable, no-transform"
|
||||
|
||||
reverse_proxy /api/* backend:8080
|
||||
reverse_proxy /auth/send-code backend:8080
|
||||
reverse_proxy /auth/verify-code backend:8080
|
||||
reverse_proxy /auth/google backend:8080
|
||||
reverse_proxy /auth/logout backend:8080
|
||||
reverse_proxy /ws backend:8080
|
||||
reverse_proxy /ws/* backend:8080
|
||||
reverse_proxy /health backend:8080
|
||||
reverse_proxy /health/* backend:8080
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
@@ -37,9 +37,11 @@ RUN pnpm install --frozen-lockfile --offline
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_WS_URL
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV STANDALONE=true
|
||||
|
||||
|
||||
25
README.md
25
README.md
@@ -115,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
@@ -169,3 +184,13 @@ make dev
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -117,6 +117,21 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -157,3 +172,13 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
12
apps/desktop/.env.production
Normal file
12
apps/desktop/.env.production
Normal file
@@ -0,0 +1,12 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
2
apps/desktop/.gitignore
vendored
2
apps/desktop/.gitignore
vendored
@@ -4,3 +4,5 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
# CLI binary bundled at build time (from server/bin/)
|
||||
resources/bin/
|
||||
|
||||
24
apps/desktop/build/entitlements.mac.plist
Normal file
24
apps/desktop/build/entitlements.mac.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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>
|
||||
<!-- Electron / V8 need JIT and unsigned executable memory under the
|
||||
hardened runtime. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Required so the app can spawn the bundled `multica` Go binary and
|
||||
any other child processes (e.g. agent CLIs) without Gatekeeper
|
||||
blocking exec. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- Network client — the daemon talks to the backend + GitHub releases. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,10 +19,16 @@ mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
notarize: false
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
|
||||
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
|
||||
# unaffected because `pnpm package` already requires the Developer ID
|
||||
# signing cert — notarization is a strict superset.
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
|
||||
@@ -1,41 +1,26 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import { loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const remoteApi = env.VITE_REMOTE_API;
|
||||
const remoteWs = remoteApi?.replace(/^https/, "wss").replace(/^http/, "ws");
|
||||
|
||||
return {
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
...(remoteApi && {
|
||||
proxy: {
|
||||
"/api": { target: remoteApi, changeOrigin: true },
|
||||
"/auth": { target: remoteApi, changeOrigin: true },
|
||||
"/uploads": { target: remoteApi, changeOrigin: true },
|
||||
"/ws": { target: remoteWs, changeOrigin: true, ws: true },
|
||||
},
|
||||
}),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import globals from "globals";
|
||||
import reactConfig from "@multica/eslint-config/react";
|
||||
|
||||
export default [
|
||||
...reactConfig,
|
||||
{ ignores: ["out/", "dist/"] },
|
||||
{
|
||||
files: ["scripts/**/*.{mjs,js}"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
"private": true,
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:remote": "electron-vite dev --mode remote",
|
||||
"build": "electron-vite build",
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"dev": "pnpm run bundle-cli && electron-vite dev",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "electron-builder",
|
||||
"package": "node scripts/package.mjs",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22,8 +23,8 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
@@ -47,6 +48,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
110
apps/desktop/scripts/bundle-cli.mjs
Normal file
110
apps/desktop/scripts/bundle-cli.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the `multica` CLI from server/cmd/multica and copies the binary
|
||||
// into apps/desktop/resources/bin/ so electron-vite (dev) and electron-
|
||||
// builder (prod) pick it up. Running this on every dev/build/package
|
||||
// invocation guarantees the bundled CLI always matches the current Go
|
||||
// source — no more stale binary surprises. Go's build cache makes the
|
||||
// no-op case (nothing changed) effectively free.
|
||||
//
|
||||
// ldflags mirror `make build` so `multica --version` reports a meaningful
|
||||
// version / commit / date.
|
||||
//
|
||||
// Graceful: if `go` is not installed (e.g. frontend-only contributor), we
|
||||
// skip the build and fall through to auto-install at runtime. A genuine
|
||||
// Go compile error is fatal — you want that to block dev, not hide.
|
||||
|
||||
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function hasGo() {
|
||||
try {
|
||||
execSync("go version", { stdio: "pipe" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await access(p, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasGo()) {
|
||||
const version = sh("git describe --tags --always --dirty") || "dev";
|
||||
const commit = sh("git rev-parse --short HEAD") || "unknown";
|
||||
const date = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
);
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
"build",
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
join("bin", binName),
|
||||
"./cmd/multica",
|
||||
],
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[bundle-cli] `go` not found in PATH — skipping CLI build. " +
|
||||
"Desktop will use whatever is already in resources/bin/, or fall back " +
|
||||
"to auto-installing the latest release at runtime.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await exists(srcBinary))) {
|
||||
console.warn(
|
||||
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
|
||||
`auto-installing the latest release at runtime.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(srcBinary, destBinary);
|
||||
await chmod(destBinary, 0o755);
|
||||
|
||||
// macOS: ad-hoc sign so Gatekeeper doesn't complain when the parent app
|
||||
// (which itself may be unsigned in dev) spawns the child.
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
execSync(`codesign -s - --force ${JSON.stringify(destBinary)}`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal. Unsigned binaries still run when the parent app is trusted.
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[bundle-cli] bundled ${srcBinary} → ${destBinary}`);
|
||||
122
apps/desktop/scripts/package.mjs
Normal file
122
apps/desktop/scripts/package.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
// Wrapper around `electron-builder` that keeps the Desktop version in
|
||||
// lockstep with the CLI. Both are derived from `git describe --tags
|
||||
// --always --dirty` — the same source GoReleaser reads for the CLI
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Runs the existing bundle-cli.mjs first (so the Go binary is compiled
|
||||
// and copied into resources/bin/), then invokes electron-builder with
|
||||
// `-c.extraMetadata.version=<derived>` so the override applies at build
|
||||
// time without mutating the tracked package.json.
|
||||
//
|
||||
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
|
||||
// unchanged (e.g. `--mac --arm64`).
|
||||
//
|
||||
// The `normalizeGitVersion` helper is exported so tests can cover the
|
||||
// version-derivation logic without shelling out.
|
||||
|
||||
import { execFileSync, spawnSync, execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(here, "..");
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transformation from the `git describe --tags --always --dirty`
|
||||
* output to the value we feed into electron-builder's extraMetadata.version.
|
||||
*
|
||||
* - empty input → null (caller should fall back)
|
||||
* - "v0.1.36" → "0.1.36"
|
||||
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
|
||||
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
|
||||
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
|
||||
*
|
||||
* Leading `v` is stripped so the result is valid semver for package.json.
|
||||
*/
|
||||
export function normalizeGitVersion(raw) {
|
||||
if (!raw) return null;
|
||||
const stripped = raw.replace(/^v/, "");
|
||||
if (!/^\d/.test(stripped)) {
|
||||
// No reachable tag — `git describe` fell back to just the commit hash.
|
||||
return `0.0.0-${stripped}`;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
} else {
|
||||
console.warn(
|
||||
"[package] could not derive version from git; falling back to package.json",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: assemble electron-builder args.
|
||||
const passthrough = process.argv.slice(2);
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 4: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
// credentials, and would otherwise hit a hard failure at the notarize
|
||||
// step. Detect the missing env and flip notarize off for this run only.
|
||||
if (!process.env.APPLE_TEAM_ID) {
|
||||
console.warn(
|
||||
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
|
||||
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
|
||||
);
|
||||
builderArgs.push("-c.mac.notarize=false");
|
||||
}
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
|
||||
// Step 5: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main();
|
||||
}
|
||||
39
apps/desktop/scripts/package.test.mjs
Normal file
39
apps/desktop/scripts/package.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGitVersion } from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
expect(normalizeGitVersion("")).toBe(null);
|
||||
expect(normalizeGitVersion(null)).toBe(null);
|
||||
expect(normalizeGitVersion(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("strips the leading v on a clean tag", () => {
|
||||
expect(normalizeGitVersion("v0.1.36")).toBe("0.1.36");
|
||||
expect(normalizeGitVersion("v1.0.0")).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("preserves the prerelease suffix between tags", () => {
|
||||
expect(normalizeGitVersion("v0.1.35-14-gf1415e96")).toBe(
|
||||
"0.1.35-14-gf1415e96",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the dirty suffix on a modified worktree", () => {
|
||||
expect(normalizeGitVersion("v0.1.35-14-gf1415e96-dirty")).toBe(
|
||||
"0.1.35-14-gf1415e96-dirty",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles v-prefixed prerelease tags", () => {
|
||||
expect(normalizeGitVersion("v1.0.0-alpha")).toBe("1.0.0-alpha");
|
||||
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
|
||||
});
|
||||
|
||||
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
|
||||
// `git describe --tags --always` returns just the short commit hash
|
||||
// when there are no tags in the history at all.
|
||||
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
|
||||
});
|
||||
});
|
||||
173
apps/desktop/src/main/cli-bootstrap.ts
Normal file
173
apps/desktop/src/main/cli-bootstrap.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { app } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { createReadStream, createWriteStream, existsSync } from "fs";
|
||||
import { chmod, mkdir, rename, rm } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
|
||||
// launch, so users never have to brew-install anything. Build-time decoupled:
|
||||
// we don't bundle the binary into the .app, we download whatever the upstream
|
||||
// release is at first run.
|
||||
|
||||
const GITHUB_LATEST_BASE =
|
||||
"https://github.com/multica-ai/multica/releases/latest/download";
|
||||
|
||||
function platformAssetName(): string {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!os || !arch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const ext = process.platform === "win32" ? "zip" : "tar.gz";
|
||||
return `multica_${os}_${arch}.${ext}`;
|
||||
}
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
|
||||
export function managedCliPath(): string {
|
||||
return join(app.getPath("userData"), "bin", binaryName());
|
||||
}
|
||||
|
||||
function run(cmd: string, args: string[], cwd?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { cwd }, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<void> {
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`download failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
// Node's fetch returns a web ReadableStream; adapt to a Node stream for pipeline.
|
||||
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
await pipeline(nodeStream, createWriteStream(dest));
|
||||
}
|
||||
|
||||
// Fetch goreleaser's published checksums.txt and parse it into a
|
||||
// filename → sha256 lookup. Format is `<hex> <filename>` per line.
|
||||
async function fetchChecksums(): Promise<Map<string, string>> {
|
||||
const url = `${GITHUB_LATEST_BASE}/checksums.txt`;
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`checksums.txt fetch failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
const map = new Map<string, string>();
|
||||
for (const rawLine of text.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const match = line.match(/^([a-f0-9]{64})\s+\*?(\S+)$/i);
|
||||
if (match) map.set(match[2], match[1].toLowerCase());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function sha256OfFile(path: string): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
await pipeline(createReadStream(path), hash);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function verifyChecksum(
|
||||
archivePath: string,
|
||||
assetName: string,
|
||||
): Promise<void> {
|
||||
const checksums = await fetchChecksums();
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const actual = await sha256OfFile(archivePath);
|
||||
if (actual.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
await mkdir(dest, { recursive: true });
|
||||
// Modern OSes all ship a `tar` that auto-detects tar.gz and zip:
|
||||
// - macOS/Linux: GNU tar or bsdtar
|
||||
// - Windows 10+: bsdtar is bundled as `tar.exe` since build 17063
|
||||
await run("tar", ["-xf", archive, "-C", dest]);
|
||||
}
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const assetName = platformAssetName();
|
||||
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
await mkdir(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const archivePath = join(workDir, assetName);
|
||||
console.log(`[cli-bootstrap] downloading ${url}`);
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
|
||||
const extractedBin = join(workDir, binaryName());
|
||||
if (!existsSync(extractedBin)) {
|
||||
throw new Error(
|
||||
`archive ${assetName} did not contain ${binaryName()} at its root`,
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rename(extractedBin, target);
|
||||
await chmod(target, 0o755);
|
||||
|
||||
// macOS: ad-hoc sign so spawning the child never hits a gatekeeper quirk.
|
||||
// Non-fatal: unsigned binaries still execute when the parent app is trusted.
|
||||
if (process.platform === "darwin") {
|
||||
await run("codesign", ["-s", "-", "--force", target]).catch((err) => {
|
||||
console.warn("[cli-bootstrap] ad-hoc codesign failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[cli-bootstrap] installed CLI at ${target}`);
|
||||
return target;
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to a usable `multica` binary. If one is already present at
|
||||
* the managed userData location, returns it immediately. Otherwise downloads
|
||||
* the latest release asset for the current platform and installs it.
|
||||
*/
|
||||
export async function ensureManagedCli(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target)) return target;
|
||||
return installFresh();
|
||||
}
|
||||
901
apps/desktop/src/main/daemon-manager.ts
Normal file
901
apps/desktop/src/main/daemon-manager.ts
Normal file
@@ -0,0 +1,901 @@
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
writeFile,
|
||||
mkdir,
|
||||
rm,
|
||||
open,
|
||||
stat,
|
||||
} from "fs/promises";
|
||||
import {
|
||||
existsSync,
|
||||
watchFile,
|
||||
unwatchFile,
|
||||
type StatsListener,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
|
||||
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
|
||||
const DEFAULT_HEALTH_PORT = 19514;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
|
||||
const LOG_TAIL_RETRY_MS = 2_000;
|
||||
const LOG_TAIL_MAX_RETRIES = 5;
|
||||
|
||||
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
|
||||
|
||||
interface ActiveProfile {
|
||||
name: string; // "" = default profile
|
||||
port: number;
|
||||
}
|
||||
|
||||
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let logTailWatcher: { path: string; listener: StatsListener } | null = null;
|
||||
let currentState: DaemonStatus["state"] = "installing_cli";
|
||||
let getMainWindow: () => BrowserWindow | null = () => null;
|
||||
let operationInProgress = false;
|
||||
let cachedCliBinary: string | null | undefined = undefined;
|
||||
let cliResolvePromise: Promise<string | null> | null = null;
|
||||
let cachedCliBinaryVersion: string | null | undefined = undefined;
|
||||
// Set when a CLI version mismatch was detected but the running daemon is
|
||||
// busy executing tasks. The poll loop retries the check on each tick and
|
||||
// fires the restart once active_task_count drops to 0.
|
||||
let pendingVersionRestart = false;
|
||||
let targetApiBaseUrl: string | null = null;
|
||||
let activeProfile: ActiveProfile | null = null;
|
||||
|
||||
// Serialize all writes to any profile config file. Multiple paths
|
||||
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
|
||||
// may try to write concurrently; chaining them avoids interleaved writes
|
||||
// corrupting the JSON.
|
||||
let configWriteChain: Promise<void> = Promise.resolve();
|
||||
|
||||
// Keep the Go impl in sync: server/cmd/multica/cmd_daemon.go healthPortForProfile.
|
||||
function healthPortForProfile(profile: string): number {
|
||||
if (!profile) return DEFAULT_HEALTH_PORT;
|
||||
let sum = 0;
|
||||
for (const b of Buffer.from(profile, "utf-8")) sum += b;
|
||||
return DEFAULT_HEALTH_PORT + 1 + (sum % 1000);
|
||||
}
|
||||
|
||||
function profileDir(profile: string): string {
|
||||
return profile
|
||||
? join(homedir(), ".multica", "profiles", profile)
|
||||
: join(homedir(), ".multica");
|
||||
}
|
||||
|
||||
function profileConfigPath(profile: string): string {
|
||||
return join(profileDir(profile), "config.json");
|
||||
}
|
||||
|
||||
function profileLogPath(profile: string): string {
|
||||
return join(profileDir(profile), "daemon.log");
|
||||
}
|
||||
|
||||
// Sidecar file that records which Multica user the cached PAT in config.json
|
||||
// was minted for. The Go CLI/daemon never read or write this file, so it
|
||||
// survives Go-side config rewrites. Used to detect user switches and mint a
|
||||
// fresh PAT instead of reusing a token that belongs to a previous user.
|
||||
function profileUserIdPath(profile: string): string {
|
||||
return join(profileDir(profile), ".desktop-user-id");
|
||||
}
|
||||
|
||||
async function readProfileUserId(profile: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(profileUserIdPath(profile), "utf-8");
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeProfileUserId(
|
||||
profile: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await mkdir(profileDir(profile), { recursive: true });
|
||||
await writeFile(profileUserIdPath(profile), userId, "utf-8");
|
||||
}
|
||||
|
||||
async function removeProfileUserId(profile: string): Promise<void> {
|
||||
try {
|
||||
await rm(profileUserIdPath(profile));
|
||||
} catch {
|
||||
// Already gone — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(u: string): string {
|
||||
if (!u) return "";
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
|
||||
} catch {
|
||||
return u.replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function urlsMatch(a: string, b: string): boolean {
|
||||
const na = normalizeUrl(a);
|
||||
const nb = normalizeUrl(b);
|
||||
return na.length > 0 && na === nb;
|
||||
}
|
||||
|
||||
function sendStatus(status: DaemonStatus): void {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("daemon:status", status);
|
||||
}
|
||||
|
||||
interface HealthPayload {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemon_id?: string;
|
||||
device_name?: string;
|
||||
server_url?: string;
|
||||
cli_version?: string;
|
||||
active_task_count?: number;
|
||||
agents?: string[];
|
||||
workspaces?: unknown[];
|
||||
}
|
||||
|
||||
async function fetchHealthAtPort(
|
||||
port: number,
|
||||
): Promise<HealthPayload | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2_000);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as HealthPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop owns a dedicated CLI profile named after the target API host, so it
|
||||
// never reads or writes the user's hand-configured profiles. Profile dir:
|
||||
// ~/.multica/profiles/desktop-<host>/
|
||||
function deriveProfileName(targetUrl: string): string {
|
||||
try {
|
||||
const url = new URL(targetUrl);
|
||||
const host = url.host.replace(/:/g, "-").toLowerCase();
|
||||
return `desktop-${host}`;
|
||||
} catch {
|
||||
return "desktop";
|
||||
}
|
||||
}
|
||||
|
||||
async function readProfileConfig(
|
||||
profile: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await readFile(profileConfigPath(profile), "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeProfileConfig(
|
||||
profile: string,
|
||||
cfg: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const op = async () => {
|
||||
await mkdir(profileDir(profile), { recursive: true });
|
||||
await writeFile(
|
||||
profileConfigPath(profile),
|
||||
JSON.stringify(cfg, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
};
|
||||
const next = configWriteChain.catch(() => {}).then(op);
|
||||
configWriteChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Desktop-owned profile for the current target API URL. Creates
|
||||
* the profile's config.json on demand with `server_url` pinned to the target.
|
||||
*
|
||||
* This function never falls back to the default profile, and never touches a
|
||||
* profile whose name doesn't start with `desktop-`, so the user's manually
|
||||
* configured CLI profiles are untouched.
|
||||
*/
|
||||
async function resolveActiveProfile(): Promise<ActiveProfile> {
|
||||
const target = targetApiBaseUrl;
|
||||
if (!target) return { name: "", port: DEFAULT_HEALTH_PORT };
|
||||
|
||||
const name = deriveProfileName(target);
|
||||
const cfg = await readProfileConfig(name);
|
||||
|
||||
if (cfg.server_url !== target) {
|
||||
cfg.server_url = target;
|
||||
await writeProfileConfig(name, cfg);
|
||||
console.log(`[daemon] initialized profile "${name}" → ${target}`);
|
||||
}
|
||||
|
||||
return { name, port: healthPortForProfile(name) };
|
||||
}
|
||||
|
||||
async function ensureActiveProfile(): Promise<ActiveProfile> {
|
||||
if (activeProfile) return activeProfile;
|
||||
activeProfile = await resolveActiveProfile();
|
||||
return activeProfile;
|
||||
}
|
||||
|
||||
function invalidateActiveProfile(): void {
|
||||
activeProfile = null;
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<DaemonStatus> {
|
||||
// While the CLI is being downloaded or has permanently failed, short-circuit
|
||||
// polling — there's nothing to probe yet and /health calls would just return
|
||||
// "stopped", which would overwrite the correct setup state in the UI.
|
||||
if (currentState === "installing_cli" || currentState === "cli_not_found") {
|
||||
return { state: currentState };
|
||||
}
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const data = await fetchHealthAtPort(active.port);
|
||||
|
||||
if (!data || data.status !== "running") {
|
||||
return {
|
||||
state: currentState === "starting" ? "starting" : "stopped",
|
||||
profile: active.name,
|
||||
};
|
||||
}
|
||||
|
||||
// Safety: if we have a target URL and the daemon on our port reports a
|
||||
// different server_url, it's not "our" daemon — drop it and re-resolve.
|
||||
if (
|
||||
targetApiBaseUrl &&
|
||||
data.server_url &&
|
||||
!urlsMatch(data.server_url, targetApiBaseUrl)
|
||||
) {
|
||||
invalidateActiveProfile();
|
||||
return { state: "stopped" };
|
||||
}
|
||||
|
||||
return {
|
||||
state: "running",
|
||||
pid: data.pid,
|
||||
uptime: data.uptime,
|
||||
daemonId: data.daemon_id,
|
||||
deviceName: data.device_name,
|
||||
agents: data.agents ?? [],
|
||||
workspaceCount: Array.isArray(data.workspaces)
|
||||
? data.workspaces.length
|
||||
: 0,
|
||||
profile: active.name,
|
||||
serverUrl: data.server_url,
|
||||
};
|
||||
}
|
||||
|
||||
function findCliOnPath(): string | null {
|
||||
const candidates = process.platform === "win32" ? ["multica.exe"] : ["multica"];
|
||||
const paths = (process.env["PATH"] ?? "").split(
|
||||
process.platform === "win32" ? ";" : ":",
|
||||
);
|
||||
if (process.platform === "darwin") {
|
||||
paths.push("/opt/homebrew/bin", "/usr/local/bin");
|
||||
}
|
||||
for (const name of candidates) {
|
||||
for (const dir of paths) {
|
||||
const full = join(dir, name);
|
||||
if (existsSync(full)) return full;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the CLI binary bundled inside the Desktop app.
|
||||
*
|
||||
* - Dev (`electron-vite dev`): `app.getAppPath()` → `apps/desktop`, resolving
|
||||
* to `apps/desktop/resources/bin/multica`. `bundle-cli.mjs` populates this
|
||||
* before dev starts, so iterating on Go changes is "make build → restart".
|
||||
* - Packaged: `app.getAppPath()` → `<Multica.app>/Contents/Resources/app.asar`.
|
||||
* electron-builder's `asarUnpack: resources/**` extracts the binary to
|
||||
* `app.asar.unpacked/`, so we swap the path segment to execute it.
|
||||
*/
|
||||
function bundledCliPath(): string {
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
return join(app.getAppPath(), "resources", "bin", binName).replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable `multica` binary path. Priority:
|
||||
* 1. Cached result from a previous successful resolve.
|
||||
* 2. Bundled binary shipped with the Desktop app (`bundle-cli.mjs`).
|
||||
* 3. Managed binary already installed in userData (`managedCliPath`).
|
||||
* 4. Download + install latest release into userData.
|
||||
* 5. `multica` on PATH (dev convenience / user-installed via brew).
|
||||
* Returns `null` only when all of the above fail.
|
||||
*
|
||||
* Bundled is preferred so Desktop iterates in lockstep with Go changes in
|
||||
* the same repo — avoids the 404 / stale-API problem when the Desktop's
|
||||
* TS side is ahead of the last published CLI release.
|
||||
*
|
||||
* This function is idempotent and safe to call concurrently — in-flight
|
||||
* installs are de-duplicated via `cliResolvePromise`.
|
||||
*/
|
||||
async function resolveCliBinary(): Promise<string | null> {
|
||||
if (cachedCliBinary !== undefined) return cachedCliBinary;
|
||||
if (cliResolvePromise) return cliResolvePromise;
|
||||
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
|
||||
const onPath = findCliOnPath();
|
||||
cachedCliBinary = onPath;
|
||||
return onPath;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await cliResolvePromise;
|
||||
} finally {
|
||||
cliResolvePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version of the currently resolved CLI binary by invoking
|
||||
* `multica version --output json`. Cached for the process lifetime — the
|
||||
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) {
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the running daemon's `cli_version` against the CLI binary we
|
||||
* would use to spawn a new one, and restarts only when safe. The decision
|
||||
* logic itself is in `version-decision.ts` (pure, unit-tested); this
|
||||
* wrapper handles the async plumbing and side effects.
|
||||
*
|
||||
* Restart is only fired when ALL of:
|
||||
* - a daemon is actually running on the active profile's port
|
||||
* - both sides report a version and the strings differ
|
||||
* - `active_task_count` is 0 (no in-flight agent work would be killed)
|
||||
*
|
||||
* On a confirmed mismatch while the daemon is busy, `pendingVersionRestart`
|
||||
* is set; the poll loop retries this function on each 5s tick and will fire
|
||||
* the restart as soon as the daemon drains.
|
||||
*/
|
||||
async function ensureRunningDaemonVersionMatches(): Promise<
|
||||
"restarted" | "deferred" | "ok" | "not_running"
|
||||
> {
|
||||
const active = await ensureActiveProfile();
|
||||
const running = await fetchHealthAtPort(active.port);
|
||||
const bundled = await getCliBinaryVersion();
|
||||
const action = decideVersionAction(bundled, running);
|
||||
|
||||
switch (action) {
|
||||
case "not_running":
|
||||
pendingVersionRestart = false;
|
||||
return "not_running";
|
||||
case "ok":
|
||||
pendingVersionRestart = false;
|
||||
return "ok";
|
||||
case "defer": {
|
||||
if (!pendingVersionRestart) {
|
||||
const activeTasks = running?.active_task_count ?? 0;
|
||||
console.log(
|
||||
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`,
|
||||
);
|
||||
}
|
||||
pendingVersionRestart = true;
|
||||
return "deferred";
|
||||
}
|
||||
case "restart":
|
||||
console.log(
|
||||
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`,
|
||||
);
|
||||
pendingVersionRestart = false;
|
||||
await restartDaemon();
|
||||
return "restarted";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
|
||||
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
|
||||
* days and signatures are tied to a specific backend instance.
|
||||
*/
|
||||
async function mintPat(jwt: string): Promise<string> {
|
||||
if (!targetApiBaseUrl) {
|
||||
throw new Error("mint PAT: target API URL not set");
|
||||
}
|
||||
const url = `${targetApiBaseUrl.replace(/\/+$/, "")}/api/tokens`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
// Omit expires_in_days → server treats as null → non-expiring PAT.
|
||||
body: JSON.stringify({ name: "Multica Desktop" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { token?: unknown };
|
||||
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
|
||||
throw new Error("mint PAT: response missing token");
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the active profile's config.json has a usable token for the daemon.
|
||||
*
|
||||
* - Input from the renderer is the user's JWT (from localStorage) plus the
|
||||
* current user's id, so we can detect session changes.
|
||||
* - If the profile already has a cached PAT (`mul_...`) AND the sidecar user
|
||||
* id matches the caller, reuse it — minting fresh on every launch would
|
||||
* accumulate garbage in the user's tokens page.
|
||||
* - On user mismatch (or first run) call POST /api/tokens with the JWT to
|
||||
* mint a fresh PAT, overwriting any stale cached PAT. This is the critical
|
||||
* path: without it, a previous user's PAT would be used by a new session.
|
||||
* - If the caller happens to pass a PAT directly, write it through.
|
||||
* - When we mint fresh and a daemon is already running, restart it so the
|
||||
* new credentials take effect (the Go daemon reads config at startup).
|
||||
*/
|
||||
async function syncToken(
|
||||
tokenFromRenderer: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const active = await ensureActiveProfile();
|
||||
const config = await readProfileConfig(active.name);
|
||||
const previousUserId = await readProfileUserId(active.name);
|
||||
const userChanged = Boolean(previousUserId) && previousUserId !== userId;
|
||||
const sameUserWithCachedPat =
|
||||
!userChanged &&
|
||||
previousUserId === userId &&
|
||||
typeof config.token === "string" &&
|
||||
config.token.startsWith("mul_");
|
||||
|
||||
let finalToken: string;
|
||||
if (tokenFromRenderer.startsWith("mul_")) {
|
||||
finalToken = tokenFromRenderer;
|
||||
} else if (sameUserWithCachedPat) {
|
||||
finalToken = config.token as string;
|
||||
} else {
|
||||
try {
|
||||
finalToken = await mintPat(tokenFromRenderer);
|
||||
console.log(
|
||||
`[daemon] minted PAT for profile "${active.name}" (user_changed=${userChanged})`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[daemon] failed to mint PAT:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
config.token = finalToken;
|
||||
if (targetApiBaseUrl) config.server_url = targetApiBaseUrl;
|
||||
await writeProfileConfig(active.name, config);
|
||||
await writeProfileUserId(active.name, userId);
|
||||
|
||||
// If we just rotated credentials onto a running daemon, restart it so the
|
||||
// in-memory token in the Go process matches the new config.
|
||||
if (userChanged) {
|
||||
try {
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (existing?.status === "running") {
|
||||
console.log(
|
||||
"[daemon] user switched — restarting daemon with new credentials",
|
||||
);
|
||||
void restartDaemon();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[daemon] restart-on-user-switch failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrefs(): Promise<DaemonPrefs> {
|
||||
try {
|
||||
const raw = await readFile(PREFS_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_PREFS, ...parsed };
|
||||
} catch {
|
||||
return { ...DEFAULT_PREFS };
|
||||
}
|
||||
}
|
||||
|
||||
async function savePrefs(prefs: DaemonPrefs): Promise<void> {
|
||||
const dir = join(homedir(), ".multica");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(PREFS_PATH, JSON.stringify(prefs, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async function clearToken(): Promise<void> {
|
||||
const active = await ensureActiveProfile();
|
||||
const config = await readProfileConfig(active.name);
|
||||
if ("token" in config) {
|
||||
delete config.token;
|
||||
await writeProfileConfig(active.name, config);
|
||||
}
|
||||
// Always drop the sidecar so a subsequent syncToken from any user is
|
||||
// treated as a fresh mint, not a reuse of a stale cached PAT.
|
||||
await removeProfileUserId(active.name);
|
||||
}
|
||||
|
||||
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
|
||||
if (operationInProgress) {
|
||||
return { success: false, error: "Another daemon operation is in progress" };
|
||||
}
|
||||
operationInProgress = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
operationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
function profileArgs(active: ActiveProfile): string[] {
|
||||
return active.name ? ["--profile", active.name] : [];
|
||||
}
|
||||
|
||||
// Env passed to every CLI child so the daemon process knows it was spawned
|
||||
// by the Desktop app. The server uses this to mark runtimes as managed and
|
||||
// hide CLI self-update UI.
|
||||
const DESKTOP_SPAWN_ENV = {
|
||||
...process.env,
|
||||
MULTICA_LAUNCHED_BY: "desktop",
|
||||
};
|
||||
|
||||
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (existing?.status === "running") {
|
||||
pollOnce();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
currentState = "starting";
|
||||
sendStatus({ state: "starting" });
|
||||
|
||||
const args = ["daemon", "start", ...profileArgs(active)];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: 20_000, env: DESKTOP_SPAWN_ENV },
|
||||
(err) => {
|
||||
if (err) {
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
resolve({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
// Stay in "starting" until pollOnce confirms /health — the CLI
|
||||
// returning 0 only means the supervisor was spawned, not that the
|
||||
// daemon process is already listening.
|
||||
pollOnce();
|
||||
resolve({ success: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
currentState = "stopping";
|
||||
sendStatus({ state: "stopping" });
|
||||
|
||||
const args = ["daemon", "stop", ...profileArgs(active)];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(bin, args, { timeout: 15_000 }, (err) => {
|
||||
if (err) {
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
resolve({ success: true });
|
||||
}
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const stopResult = await stopDaemon();
|
||||
if (!stopResult.success) return stopResult;
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
async function pollOnce(): Promise<void> {
|
||||
const status = await fetchHealth();
|
||||
currentState = status.state;
|
||||
sendStatus(status);
|
||||
// Retry a deferred version-mismatch restart once the daemon drains.
|
||||
if (pendingVersionRestart && status.state === "running") {
|
||||
void ensureRunningDaemonVersionMatches();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (statusPollTimer) return;
|
||||
pollOnce();
|
||||
statusPollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the CLI binary is available, then transitions into the normal
|
||||
* stopped/running state machine. Called once at startup and again on
|
||||
* user-triggered `daemon:retry-install`.
|
||||
*/
|
||||
async function bootstrapCli(): Promise<void> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) {
|
||||
currentState = "cli_not_found";
|
||||
sendStatus({ state: "cli_not_found" });
|
||||
return;
|
||||
}
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_TAIL_INITIAL_WINDOW_BYTES = 32 * 1024;
|
||||
const LOG_TAIL_INITIAL_LINES = 200;
|
||||
const LOG_TAIL_POLL_MS = 500;
|
||||
|
||||
async function readLogRange(
|
||||
path: string,
|
||||
startAt: number,
|
||||
length: number,
|
||||
): Promise<string> {
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, startAt);
|
||||
return buffer.subarray(0, bytesRead).toString("utf-8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sendLines(win: BrowserWindow, text: string): void {
|
||||
const lines = text.split("\n").filter((line) => line.length > 0);
|
||||
for (const line of lines) {
|
||||
win.webContents.send("daemon:log-line", line);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-platform tail -f replacement: read the tail of the file once, then
|
||||
// poll its stat with fs.watchFile and forward any new bytes since the last
|
||||
// known offset. watchFile works on macOS, Linux, and Windows; spawn("tail")
|
||||
// would silently fail on Windows.
|
||||
function startLogTail(win: BrowserWindow, retryCount = 0): void {
|
||||
stopLogTail();
|
||||
|
||||
void ensureActiveProfile().then(async (active) => {
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
if (retryCount < LOG_TAIL_MAX_RETRIES) {
|
||||
setTimeout(() => startLogTail(win, retryCount + 1), LOG_TAIL_RETRY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let position = 0;
|
||||
try {
|
||||
const initialStats = await stat(logPath);
|
||||
const windowBytes = Math.min(
|
||||
initialStats.size,
|
||||
LOG_TAIL_INITIAL_WINDOW_BYTES,
|
||||
);
|
||||
const startAt = initialStats.size - windowBytes;
|
||||
if (windowBytes > 0) {
|
||||
const text = await readLogRange(logPath, startAt, windowBytes);
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-LOG_TAIL_INITIAL_LINES);
|
||||
for (const line of lines) {
|
||||
win.webContents.send("daemon:log-line", line);
|
||||
}
|
||||
}
|
||||
position = initialStats.size;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] log tail initial read failed:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const listener: StatsListener = (curr) => {
|
||||
const target = getMainWindow();
|
||||
if (!target) return;
|
||||
// File rotated/truncated — restart from the new beginning.
|
||||
if (curr.size < position) position = 0;
|
||||
if (curr.size === position) return;
|
||||
const from = position;
|
||||
const length = curr.size - from;
|
||||
position = curr.size;
|
||||
readLogRange(logPath, from, length)
|
||||
.then((text) => sendLines(target, text))
|
||||
.catch((err) => {
|
||||
console.warn("[daemon] log tail read failed:", err);
|
||||
});
|
||||
};
|
||||
|
||||
watchFile(logPath, { interval: LOG_TAIL_POLL_MS }, listener);
|
||||
logTailWatcher = { path: logPath, listener };
|
||||
});
|
||||
}
|
||||
|
||||
function stopLogTail(): void {
|
||||
if (logTailWatcher) {
|
||||
unwatchFile(logTailWatcher.path, logTailWatcher.listener);
|
||||
logTailWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupDaemonManager(
|
||||
windowGetter: () => BrowserWindow | null,
|
||||
): void {
|
||||
getMainWindow = windowGetter;
|
||||
|
||||
ipcMain.handle("daemon:set-target-api-url", async (_e, url: string) => {
|
||||
const normalized = url || null;
|
||||
if (targetApiBaseUrl !== normalized) {
|
||||
console.log(`[daemon] target API URL set to ${normalized ?? "(none)"}`);
|
||||
targetApiBaseUrl = normalized;
|
||||
invalidateActiveProfile();
|
||||
await pollOnce();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("daemon:start", () => withGuard(() => startDaemon()));
|
||||
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
|
||||
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
|
||||
ipcMain.handle("daemon:get-status", () => fetchHealth());
|
||||
ipcMain.handle(
|
||||
"daemon:sync-token",
|
||||
(_event, token: string, userId: string) => syncToken(token, userId),
|
||||
);
|
||||
ipcMain.handle("daemon:clear-token", () => clearToken());
|
||||
ipcMain.handle("daemon:is-cli-installed", async () => {
|
||||
const bin = await resolveCliBinary();
|
||||
return bin !== null;
|
||||
});
|
||||
ipcMain.handle("daemon:retry-install", async () => {
|
||||
cachedCliBinary = undefined;
|
||||
cliResolvePromise = null;
|
||||
// A retry-install may land a new CLI at a different version; drop the
|
||||
// cached version string so the next check re-reads the binary.
|
||||
cachedCliBinaryVersion = undefined;
|
||||
await bootstrapCli();
|
||||
});
|
||||
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
|
||||
ipcMain.handle(
|
||||
"daemon:set-prefs",
|
||||
(_event, prefs: Partial<DaemonPrefs>) =>
|
||||
loadPrefs().then((cur) => {
|
||||
const merged = { ...cur, ...prefs };
|
||||
return savePrefs(merged).then(() => merged);
|
||||
}),
|
||||
);
|
||||
ipcMain.handle("daemon:auto-start", async () => {
|
||||
const prefs = await loadPrefs();
|
||||
if (!prefs.autoStart) return;
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return;
|
||||
const health = await fetchHealth();
|
||||
if (health.state === "running") {
|
||||
// Daemon is up but may be running an older CLI than the one we just
|
||||
// bundled. Restart it so the new binary actually takes effect.
|
||||
await ensureRunningDaemonVersionMatches();
|
||||
return;
|
||||
}
|
||||
await startDaemon();
|
||||
});
|
||||
|
||||
ipcMain.on("daemon:start-log-stream", () => {
|
||||
const win = getMainWindow();
|
||||
if (win) startLogTail(win);
|
||||
});
|
||||
|
||||
ipcMain.on("daemon:stop-log-stream", () => {
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
sendStatus({ state: "installing_cli" });
|
||||
void bootstrapCli();
|
||||
|
||||
let isQuitting = false;
|
||||
app.on("before-quit", (event) => {
|
||||
if (isQuitting) return;
|
||||
stopPolling();
|
||||
stopLogTail();
|
||||
|
||||
loadPrefs().then(async (prefs) => {
|
||||
if (prefs.autoStop) {
|
||||
isQuitting = true;
|
||||
event.preventDefault();
|
||||
try {
|
||||
await stopDaemon();
|
||||
} catch {
|
||||
// Best-effort stop on quit
|
||||
}
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { app, shell, BrowserWindow, ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
@@ -113,9 +114,18 @@ if (!gotTheLock) {
|
||||
return shell.openExternal(url);
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (create-workspace, onboarding) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
|
||||
if (process.platform !== "darwin") return;
|
||||
mainWindow?.setWindowButtonVisibility(!immersive);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
setupDaemonManager(() => mainWindow);
|
||||
|
||||
// macOS: deep link arrives via open-url event
|
||||
app.on("open-url", (_event, url) => {
|
||||
|
||||
88
apps/desktop/src/main/version-decision.test.ts
Normal file
88
apps/desktop/src/main/version-decision.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
|
||||
describe("decideVersionAction", () => {
|
||||
it("returns not_running when health payload is null", () => {
|
||||
expect(decideVersionAction("v1.0.0", null)).toBe("not_running");
|
||||
});
|
||||
|
||||
it("returns not_running when status is not 'running'", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.0.0", { status: "stopped", cli_version: "v1.0.0" }),
|
||||
).toBe("not_running");
|
||||
});
|
||||
|
||||
it("returns ok when bundled version is unknown (fail safe)", () => {
|
||||
expect(
|
||||
decideVersionAction(null, {
|
||||
status: "running",
|
||||
cli_version: "v1.0.0",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns ok when running daemon does not report cli_version (older daemon)", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.0.0", {
|
||||
status: "running",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns ok when versions match exactly", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.3",
|
||||
active_task_count: 5,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns restart when versions differ and daemon is idle", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
active_task_count: 0,
|
||||
}),
|
||||
).toBe("restart");
|
||||
});
|
||||
|
||||
it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
}),
|
||||
).toBe("restart");
|
||||
});
|
||||
|
||||
it("returns defer when versions differ but daemon is busy", () => {
|
||||
expect(
|
||||
decideVersionAction("v1.2.3", {
|
||||
status: "running",
|
||||
cli_version: "v1.2.2",
|
||||
active_task_count: 2,
|
||||
}),
|
||||
).toBe("defer");
|
||||
});
|
||||
|
||||
it("transitions defer → restart as tasks drain", () => {
|
||||
// Same bundled version across three observations while the daemon ages.
|
||||
const bundled = "v2.0.0";
|
||||
const base = { status: "running", cli_version: "v1.9.0" } as const;
|
||||
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 3 }),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 1 }),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
decideVersionAction(bundled, { ...base, active_task_count: 0 }),
|
||||
).toBe("restart");
|
||||
});
|
||||
});
|
||||
37
apps/desktop/src/main/version-decision.ts
Normal file
37
apps/desktop/src/main/version-decision.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Pure decision logic for the daemon version-check flow. Kept in its own
|
||||
// module so it can be unit-tested without mocking Electron, execFile, or
|
||||
// the HTTP health probe.
|
||||
|
||||
export interface VersionCheckHealth {
|
||||
status?: string;
|
||||
cli_version?: string;
|
||||
active_task_count?: number;
|
||||
}
|
||||
|
||||
export type VersionAction = "restart" | "defer" | "ok" | "not_running";
|
||||
|
||||
/**
|
||||
* Decides what the daemon-manager should do given the currently-resolved
|
||||
* bundled CLI version and the latest /health payload.
|
||||
*
|
||||
* not_running: no daemon is up, nothing to do
|
||||
* ok: versions match, OR either side is unknown (fail safe)
|
||||
* defer: versions differ but the daemon is busy — wait for drain
|
||||
* restart: versions differ and the daemon is idle — safe to restart
|
||||
*
|
||||
* Pure function: no I/O, no side effects, no module state.
|
||||
*/
|
||||
export function decideVersionAction(
|
||||
bundled: string | null,
|
||||
running: VersionCheckHealth | null,
|
||||
): VersionAction {
|
||||
if (!running || running.status !== "running") return "not_running";
|
||||
|
||||
const runningVersion = running.cli_version;
|
||||
if (!bundled || !runningVersion) return "ok";
|
||||
if (runningVersion === bundled) return "ok";
|
||||
|
||||
const activeTasks = running.active_task_count ?? 0;
|
||||
if (activeTasks > 0) return "defer";
|
||||
return "restart";
|
||||
}
|
||||
39
apps/desktop/src/preload/index.d.ts
vendored
39
apps/desktop/src/preload/index.d.ts
vendored
@@ -5,6 +5,44 @@ interface DesktopAPI {
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
profile?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
interface DaemonPrefs {
|
||||
autoStart: boolean;
|
||||
autoStop: boolean;
|
||||
}
|
||||
|
||||
interface DaemonAPI {
|
||||
start: () => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
restart: () => Promise<{ success: boolean; error?: string }>;
|
||||
getStatus: () => Promise<DaemonStatus>;
|
||||
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
|
||||
setTargetApiUrl: (url: string) => Promise<void>;
|
||||
syncToken: (token: string, userId: string) => Promise<void>;
|
||||
clearToken: () => Promise<void>;
|
||||
isCliInstalled: () => Promise<boolean>;
|
||||
getPrefs: () => Promise<DaemonPrefs>;
|
||||
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;
|
||||
autoStart: () => Promise<void>;
|
||||
retryInstall: () => Promise<void>;
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
@@ -19,6 +57,7 @@ declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
desktopAPI: DesktopAPI;
|
||||
daemonAPI: DaemonAPI;
|
||||
updater: UpdaterAPI;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,60 @@ const desktopAPI = {
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
profile?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const daemonAPI = {
|
||||
start: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:start"),
|
||||
stop: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:stop"),
|
||||
restart: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:restart"),
|
||||
getStatus: (): Promise<DaemonStatus> =>
|
||||
ipcRenderer.invoke("daemon:get-status"),
|
||||
onStatusChange: (callback: (status: DaemonStatus) => void) => {
|
||||
const handler = (_: unknown, status: DaemonStatus) => callback(status);
|
||||
ipcRenderer.on("daemon:status", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:status", handler);
|
||||
},
|
||||
setTargetApiUrl: (url: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:set-target-api-url", url),
|
||||
syncToken: (token: string, userId: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:sync-token", token, userId),
|
||||
clearToken: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:clear-token"),
|
||||
isCliInstalled: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("daemon:is-cli-installed"),
|
||||
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>
|
||||
ipcRenderer.invoke("daemon:get-prefs"),
|
||||
setPrefs: (prefs: Partial<{ autoStart: boolean; autoStop: boolean }>): Promise<{ autoStart: boolean; autoStop: boolean }> =>
|
||||
ipcRenderer.invoke("daemon:set-prefs", prefs),
|
||||
autoStart: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:auto-start"),
|
||||
retryInstall: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:retry-install"),
|
||||
startLogStream: () => ipcRenderer.send("daemon:start-log-stream"),
|
||||
stopLogStream: () => ipcRenderer.send("daemon:stop-log-stream"),
|
||||
onLogLine: (callback: (line: string) => void) => {
|
||||
const handler = (_: unknown, line: string) => callback(line);
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
@@ -38,6 +92,7 @@ const updaterAPI = {
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
|
||||
contextBridge.exposeInMainWorld("daemonAPI", daemonAPI);
|
||||
contextBridge.exposeInMainWorld("updater", updaterAPI);
|
||||
} else {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
@@ -45,5 +100,7 @@ if (process.contextIsolated) {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.desktopAPI = desktopAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.daemonAPI = daemonAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.updater = updaterAPI;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,18 @@ function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onAuthToken(async (token) => {
|
||||
try {
|
||||
await useAuthStore.getState().loginWithToken(token);
|
||||
const loggedIn = await useAuthStore.getState().loginWithToken(token);
|
||||
await window.daemonAPI.syncToken(token, loggedIn.id);
|
||||
const wsList = await api.listWorkspaces();
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
|
||||
@@ -28,6 +35,22 @@ function AppContent() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sync token and start the daemon whenever the user logs in.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
const userId = user.id;
|
||||
(async () => {
|
||||
try {
|
||||
await window.daemonAPI.syncToken(token, userId);
|
||||
await window.daemonAPI.autoStart();
|
||||
} catch (err) {
|
||||
console.error("Failed to sync daemon on login", err);
|
||||
}
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
@@ -40,14 +63,32 @@ function AppContent() {
|
||||
return <DesktopShell />;
|
||||
}
|
||||
|
||||
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, clear any cached PAT and stop the daemon so that a subsequent
|
||||
// login as a different user never inherits the previous user's credentials.
|
||||
async function handleDaemonLogout() {
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
// Best-effort — clearing is followed by stop which also hardens state.
|
||||
}
|
||||
try {
|
||||
await window.daemonAPI.stop();
|
||||
} catch {
|
||||
// Daemon may already be stopped.
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
|
||||
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
|
||||
309
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
309
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
let logIdCounter = 0;
|
||||
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleLogScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="Close"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
155
apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx
Normal file
155
apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
const unsub = window.daemonAPI.onStatusChange((s) => {
|
||||
setStatus(s);
|
||||
setActionLoading(false);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
if (!result.success) {
|
||||
setActionLoading(false);
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
)}
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
apps/desktop/src/renderer/src/components/daemon-settings-tab.tsx
Normal file
103
apps/desktop/src/renderer/src/components/daemon-settings-tab.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
async (key: keyof DaemonPrefs, value: boolean) => {
|
||||
setSaving(true);
|
||||
const updated = await window.daemonAPI.setPrefs({ [key]: value });
|
||||
setPrefs(updated);
|
||||
setSaving(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Daemon</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure how the local agent daemon behaves with the desktop app.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<SettingRow
|
||||
label="Auto-start on launch"
|
||||
description="Automatically start the daemon when the app opens and you are logged in."
|
||||
>
|
||||
<Switch
|
||||
checked={prefs.autoStart}
|
||||
onCheckedChange={(checked) => updatePref("autoStart", checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Auto-stop on quit"
|
||||
description="Stop the daemon when the desktop app is closed. Disable this to keep the daemon running in the background."
|
||||
>
|
||||
<Switch
|
||||
checked={prefs.autoStop}
|
||||
onCheckedChange={(checked) => updatePref("autoStop", checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm font-medium">CLI Status</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{cliInstalled === null
|
||||
? "Checking…"
|
||||
: cliInstalled
|
||||
? "multica CLI is installed and available in PATH."
|
||||
: "multica CLI not found. Install it to enable daemon management."}
|
||||
</p>
|
||||
{cliInstalled === false && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
window.desktopAPI.openExternal(
|
||||
"https://github.com/multica-ai/multica#cli-installation",
|
||||
)
|
||||
}
|
||||
>
|
||||
Installation Guide
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
|
||||
import {
|
||||
SidebarProvider,
|
||||
useSidebar,
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
@@ -28,6 +32,7 @@ function SidebarTopBar() {
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
@@ -35,6 +40,7 @@ function SidebarTopBar() {
|
||||
<button
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
@@ -44,6 +50,23 @@ function SidebarTopBar() {
|
||||
);
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is collapsed, we pad the left side so tabs don't land under the macOS
|
||||
// traffic lights (which live at roughly x=16..68 and always hit-test above HTML).
|
||||
function MainTopBar() {
|
||||
const { state } = useSidebar();
|
||||
const sidebarCollapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn("h-12 shrink-0", sidebarCollapsed && "pl-20")}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<TabBar />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function useInternalLinkHandler() {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
@@ -78,13 +101,7 @@ export function DesktopShell() {
|
||||
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
{/* Tab bar + drag region */}
|
||||
<header
|
||||
className="h-12 shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<TabBar />
|
||||
</header>
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
|
||||
@@ -6,11 +6,27 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Geist font: define CSS variables that tokens.css @theme inline references.
|
||||
Web app gets these from next/font/google; desktop must set them explicitly. */
|
||||
/* Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
|
||||
Web app uses the same stack via next/font/google in apps/web/app/layout.tsx —
|
||||
keep the CJK fallback tail in sync across both files. The Inter primary family
|
||||
differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
|
||||
fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
|
||||
Both resolve to Inter glyphs, so rendering is identical in practice.
|
||||
Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
|
||||
the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
|
||||
Per-character fallback: Latin chars render with Inter, Chinese chars with
|
||||
PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
|
||||
|
||||
Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
|
||||
non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
|
||||
would falsely signal alignment guarantees. Browser default fallback handles
|
||||
the rare mixed case correctly. */
|
||||
:root {
|
||||
--font-sans: "Geist Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
|
||||
sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
@source "../../../../../packages/ui/**/*.tsx";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "@fontsource/geist-sans/400.css";
|
||||
import "@fontsource/geist-sans/500.css";
|
||||
import "@fontsource/geist-sans/600.css";
|
||||
import "@fontsource/geist-sans/700.css";
|
||||
// Inter variable font covers all weights (100-900) in a single file.
|
||||
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
|
||||
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AutopilotDetailPage as AutopilotDetail } from "@multica/views/autopilots/components";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { autopilotDetailOptions } from "@multica/core/autopilots/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AutopilotDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data } = useQuery(autopilotDetailOptions(wsId, id!));
|
||||
|
||||
useDocumentTitle(data ? `⚡ ${data.autopilot.title}` : "Autopilot");
|
||||
|
||||
if (!id) return null;
|
||||
return <AutopilotDetail autopilotId={id} />;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
@@ -64,7 +69,7 @@ export function DesktopNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[pathname],
|
||||
);
|
||||
@@ -107,7 +112,7 @@ export function TabNavigationProvider({
|
||||
const newTabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(newTabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[router, location],
|
||||
);
|
||||
|
||||
@@ -8,14 +8,22 @@ import {
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
@@ -47,6 +55,18 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingRoute() {
|
||||
const nav = useNavigation();
|
||||
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
const matches = useMatches();
|
||||
const match = matches.find((m) => (m.params as { id?: string }).id);
|
||||
const id = (match?.params as { id?: string })?.id ?? "";
|
||||
return <InvitePage invitationId={id} />;
|
||||
}
|
||||
|
||||
/** Route definitions shared by all tabs (no layout wrapper). */
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
@@ -69,6 +89,16 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <ProjectDetailPage />,
|
||||
handle: { title: "Project" },
|
||||
},
|
||||
{
|
||||
path: "autopilots",
|
||||
element: <AutopilotsPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "autopilots/:id",
|
||||
element: <AutopilotDetailPage />,
|
||||
handle: { title: "Autopilot" },
|
||||
},
|
||||
{
|
||||
path: "my-issues",
|
||||
element: <MyIssuesPage />,
|
||||
@@ -76,15 +106,36 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage />,
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "onboarding",
|
||||
element: <OnboardingRoute />,
|
||||
handle: { title: "Get Started" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsPage />,
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
|
||||
53
apps/desktop/src/shared/daemon-types.ts
Normal file
53
apps/desktop/src/shared/daemon-types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type DaemonState =
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "stopping"
|
||||
| "installing_cli"
|
||||
| "cli_not_found";
|
||||
|
||||
export interface DaemonStatus {
|
||||
state: DaemonState;
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
/** CLI profile this daemon belongs to. Empty string means the default profile. */
|
||||
profile?: string;
|
||||
/** Backend URL the daemon connects to. */
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
export interface DaemonPrefs {
|
||||
autoStart: boolean;
|
||||
autoStop: boolean;
|
||||
}
|
||||
|
||||
export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
|
||||
running: "bg-emerald-500",
|
||||
stopped: "bg-muted-foreground/40",
|
||||
starting: "bg-amber-500 animate-pulse",
|
||||
stopping: "bg-amber-500 animate-pulse",
|
||||
installing_cli: "bg-sky-500 animate-pulse",
|
||||
cli_not_found: "bg-red-500",
|
||||
};
|
||||
|
||||
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
starting: "Starting…",
|
||||
stopping: "Stopping…",
|
||||
installing_cli: "Setting up…",
|
||||
cli_not_found: "Setup Failed",
|
||||
};
|
||||
|
||||
export function formatUptime(uptime?: string): string {
|
||||
if (!uptime) return "";
|
||||
const match = uptime.match(/(?:(\d+)h)?(\d+)m/);
|
||||
if (!match) return uptime;
|
||||
const h = match[1] ? `${match[1]}h ` : "";
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
10
apps/desktop/vitest.config.ts
Normal file
10
apps/desktop/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"],
|
||||
environment: "node",
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -78,12 +78,13 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, or `hermes`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one supported AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
|
||||
- [Codex](https://github.com/openai/codex) (`codex`)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini`)
|
||||
- OpenCode (`opencode`)
|
||||
- OpenClaw (`openclaw`)
|
||||
- Hermes (`hermes`)
|
||||
|
||||
@@ -88,6 +88,7 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | Google's coding agent |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
@@ -131,6 +132,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Go to [multica.ai](https://multica.ai) and create an account.
|
||||
|
||||
## 2. Install the CLI and start the daemon
|
||||
|
||||
Give this instruction to your AI agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):
|
||||
Give this instruction to your AI agent (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, etc.):
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
@@ -45,7 +45,7 @@ Then configure, authenticate, and start the daemon:
|
||||
multica setup
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
## 3. Verify your runtime
|
||||
|
||||
@@ -55,7 +55,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
## 4. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
## 5. Assign your first task
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ brew install multica-ai/tap/multica
|
||||
You also need at least one AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` on PATH)
|
||||
- OpenCode (`opencode` on PATH)
|
||||
- OpenClaw (`openclaw` on PATH)
|
||||
- Hermes (`hermes` on PATH)
|
||||
@@ -233,6 +234,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
|
||||
## Database Setup
|
||||
|
||||
@@ -312,6 +315,8 @@ api.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
For a single-domain setup, route the frontend and backend through one hostname and forward `/api`, `/auth`, `/ws`, and `/health` to the backend while sending everything else to the frontend. This repository now includes a root `Caddyfile` and `docker-compose.selfhost.yml` service for that pattern.
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -15,7 +15,7 @@ When an agent is assigned a task in Multica:
|
||||
|
||||
1. The daemon detects the task assignment
|
||||
2. It creates an isolated workspace directory
|
||||
3. It spawns the appropriate agent CLI (Claude Code, Codex, OpenClaw, or OpenCode)
|
||||
3. It spawns the appropriate agent CLI (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes)
|
||||
4. The agent executes autonomously, streaming progress back to Multica
|
||||
5. Results are reported — success, failure, or blockers
|
||||
|
||||
@@ -29,21 +29,28 @@ Real-time progress is streamed via WebSocket so you can follow along in the Mult
|
||||
|----------|-------------|-------------|
|
||||
| Claude Code | `claude` | Anthropic's coding agent |
|
||||
| Codex | `codex` | OpenAI's coding agent |
|
||||
| Gemini CLI | `gemini` | Google's coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
|
||||
The daemon auto-detects which CLIs are available on your PATH and registers them as available runtimes.
|
||||
|
||||
## Reusable Skills
|
||||
|
||||
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
- Deployments
|
||||
- Migrations
|
||||
- Code reviews
|
||||
- Common patterns
|
||||
|
||||
Skills are shared across the workspace, so any agent (or human) can leverage them.
|
||||
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
|
||||
|
||||
## Multi-Workspace Support
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Once you have the CLI installed (or signed up for [Multica Cloud](https://multic
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
|
||||
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) available on your PATH.
|
||||
|
||||
## 2. Verify your runtime
|
||||
|
||||
@@ -21,7 +21,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
## 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
## 4. Assign your first task
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Multica — the open-source managed agents platform. Turn coding ag
|
||||
|
||||
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. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
|
||||
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. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **Gemini CLI**, **OpenClaw**, **OpenCode**, and **Hermes**.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -24,7 +24,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
|
||||
| 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, Codex, OpenClaw, or OpenCode |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes |
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
@@ -35,7 +35,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (runs on your machine)
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
│Gemini/Hermes │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
|
||||
24
apps/web/app/(auth)/invite/[id]/page.tsx
Normal file
24
apps/web/app/(auth)/invite/[id]/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Redirect to login if not authenticated, with a redirect back to this page.
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace(`/login?next=/invite/${params.id}`);
|
||||
}
|
||||
}, [isLoading, user, router, params.id]);
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return <InvitePage invitationId={params.id} />;
|
||||
}
|
||||
@@ -37,6 +37,15 @@ function LoginPageContent() {
|
||||
router.push(ws ? nextUrl : "/onboarding");
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
// can redirect to the right place after login.
|
||||
const googleState = [
|
||||
platform === "desktop" ? "platform:desktop" : "",
|
||||
nextUrl !== "/issues" ? `next:${nextUrl}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
return (
|
||||
<LoginPage
|
||||
onSuccess={handleSuccess}
|
||||
@@ -45,7 +54,7 @@ function LoginPageContent() {
|
||||
? {
|
||||
clientId: googleClientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
state: platform === "desktop" ? "platform:desktop" : undefined,
|
||||
state: googleState,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
13
apps/web/app/(dashboard)/autopilots/[id]/page.tsx
Normal file
13
apps/web/app/(dashboard)/autopilots/[id]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { AutopilotDetailPage } from "@multica/views/autopilots/components";
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <AutopilotDetailPage autopilotId={id} />;
|
||||
}
|
||||
7
apps/web/app/(dashboard)/autopilots/page.tsx
Normal file
7
apps/web/app/(dashboard)/autopilots/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
|
||||
export default function Page() {
|
||||
return <AutopilotsPage />;
|
||||
}
|
||||
@@ -39,8 +39,11 @@ function CallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = searchParams.get("state");
|
||||
const isDesktop = state === "platform:desktop";
|
||||
const state = searchParams.get("state") || "";
|
||||
const stateParts = state.split(",");
|
||||
const isDesktop = stateParts.includes("platform:desktop");
|
||||
const nextPart = stateParts.find((p) => p.startsWith("next:"));
|
||||
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
@@ -63,7 +66,9 @@ function CallbackContent() {
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
const ws = await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push(ws ? "/issues" : "/onboarding");
|
||||
// Honor the ?next= redirect if present (e.g. /invite/{id})
|
||||
const defaultDest = ws ? "/issues" : "/onboarding";
|
||||
router.push(nextUrl || defaultDest);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -7,8 +7,38 @@ import { WebProviders } from "@/components/web-providers";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||
// Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
|
||||
// Desktop app uses the same stack via apps/desktop/src/renderer/src/globals.css —
|
||||
// keep the CJK fallback tail in sync across both files. The Inter primary family
|
||||
// differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
|
||||
// fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
|
||||
// Both resolve to Inter glyphs, so rendering is identical in practice.
|
||||
// Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
|
||||
// the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
|
||||
// Per-character fallback: Latin chars render with Inter, Chinese chars with
|
||||
// PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
fallback: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans CJK SC",
|
||||
"sans-serif",
|
||||
],
|
||||
});
|
||||
// Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
|
||||
// non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
|
||||
// here would falsely signal alignment guarantees. Browser default fallback handles
|
||||
// the rare mixed case correctly.
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -59,7 +89,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLocale } from "../i18n";
|
||||
import {
|
||||
ClaudeCodeLogo,
|
||||
CodexLogo,
|
||||
GeminiCliLogo,
|
||||
OpenClawLogo,
|
||||
OpenCodeLogo,
|
||||
GitHubMark,
|
||||
@@ -70,6 +71,10 @@ export function LandingHero() {
|
||||
<CodexLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Codex</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<GeminiCliLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Gemini CLI</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<OpenClawLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">OpenClaw</span>
|
||||
|
||||
@@ -136,6 +136,19 @@ export function OpenClawLogo({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function GeminiCliLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 0C12 0 12 8 8 12C12 12 12 12 12 24C12 24 12 16 16 12C12 12 12 12 12 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenCodeLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -277,6 +277,32 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
title: "Gemini CLI & Agent Env Vars",
|
||||
changes: [],
|
||||
features: [
|
||||
"Google Gemini CLI as a new agent runtime with live log streaming",
|
||||
"Custom environment variables for agents (router/proxy mode) with dedicated settings tab",
|
||||
"\"Set parent issue\" and \"Add sub-issue\" actions in issue context menu",
|
||||
"CLI `--parent` flag for issue update and `--content-stdin` for piping comment content",
|
||||
"Sub-issues inherit parent project automatically",
|
||||
],
|
||||
improvements: [
|
||||
"Editor bubble menu and link preview rewritten for reliability",
|
||||
"OpenClaw backend P0+P1 improvements (multi-line JSON, incremental parsing)",
|
||||
"Self-hosted WebSocket URL auto-derived for LAN access",
|
||||
],
|
||||
fixes: [
|
||||
"S3 upload keys scoped by workspace (security)",
|
||||
"Workspace membership validation for subscriptions and uploads (security)",
|
||||
"Active tasks auto-cancelled when issue status changes to cancelled",
|
||||
"Agent task stall when process hangs on stdout",
|
||||
"Daemon trigger prompt now embeds the actual triggering comment content",
|
||||
"Login and dashboard redirect stability improvements",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.28",
|
||||
date: "2026-04-13",
|
||||
|
||||
@@ -277,6 +277,32 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
title: "Gemini CLI 与 Agent 环境变量",
|
||||
changes: [],
|
||||
features: [
|
||||
"Google Gemini CLI 作为新的 Agent 运行时,支持实时日志流",
|
||||
"Agent 自定义环境变量(router/proxy 模式),新增专用设置标签页",
|
||||
"Issue 右键菜单新增「设置父 Issue」和「添加子 Issue」",
|
||||
"CLI `--parent` 更新父 Issue,`--content-stdin` 管道输入评论内容",
|
||||
"子 Issue 自动继承父级项目",
|
||||
],
|
||||
improvements: [
|
||||
"编辑器气泡菜单和链接预览重写",
|
||||
"OpenClaw 后端 P0+P1 优化(多行 JSON、增量解析)",
|
||||
"自部署 WebSocket URL 自动适配局域网访问",
|
||||
],
|
||||
fixes: [
|
||||
"S3 上传路径按工作区隔离(安全)",
|
||||
"订阅和上传新增工作区成员身份校验(安全)",
|
||||
"Issue 状态改为已取消时自动终止进行中的任务",
|
||||
"Agent 进程 stdout 挂起导致任务卡住",
|
||||
"Daemon 触发提示现在嵌入实际的触发评论内容",
|
||||
"登录和仪表盘跳转稳定性改进",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.28",
|
||||
date: "2026-04-13",
|
||||
|
||||
@@ -45,6 +45,10 @@ const nextConfig: NextConfig = {
|
||||
source: "/auth/:path*",
|
||||
destination: `${remoteApiUrl}/auth/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/uploads/:path*",
|
||||
destination: `${remoteApiUrl}/uploads/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,6 +54,8 @@ export const mockAgents: Agent[] = [
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
# # Edit .env — change JWT_SECRET at minimum
|
||||
# docker compose -f docker-compose.selfhost.yml up -d
|
||||
#
|
||||
# Frontend: http://localhost:3000
|
||||
# Backend: http://localhost:8080 (also used by CLI/daemon)
|
||||
# Frontend: https://$TASK_DOMAIN (via Caddy reverse proxy)
|
||||
# Backend: internal on backend:8080 (health exposed at /health through Caddy)
|
||||
|
||||
name: multica
|
||||
|
||||
@@ -35,12 +35,13 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
- "127.0.0.1:${PORT:-8080}:8080"
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
|
||||
APP_ENV: ${APP_ENV:-}
|
||||
PORT: "8080"
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-https://${TASK_DOMAIN:-localhost}}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
@@ -60,15 +61,35 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
REMOTE_API_URL: ${REMOTE_API_URL:-http://backend:8080}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
TASK_DOMAIN: ${TASK_DOMAIN:-localhost}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
@@ -69,10 +69,12 @@
|
||||
|
||||
| 角色 | 字体 | 用途 |
|
||||
|------|------|------|
|
||||
| 正文/UI | Geist Sans (`--font-sans`) | 所有界面文字的默认字体 |
|
||||
| 正文/UI | Inter (`--font-sans`) | 所有界面文字的默认字体;CJK 字符自动 fallback 到系统字体(PingFang SC / Microsoft YaHei / Noto Sans CJK SC) |
|
||||
| 代码/数据 | Geist Mono (`--font-mono`) | 代码块、ID、时间戳、等宽数据 |
|
||||
| 标题 | `--font-heading`(= `--font-sans`) | 页面标题、区块标题 |
|
||||
|
||||
字体栈在 `apps/web/app/layout.tsx` 和 `apps/desktop/src/renderer/src/globals.css` 两处声明,修改时需同步。
|
||||
|
||||
### 3.2 字号纪律
|
||||
|
||||
**整个项目只使用 3 个核心字号 + 1 个特殊字号:**
|
||||
@@ -98,7 +100,7 @@
|
||||
| `font-normal` (400) | 正文、描述、大部分文字 |
|
||||
| `font-medium` (500) | 标签、按钮、导航项、标题、选中状态 |
|
||||
|
||||
**禁止** `font-bold` / `font-semibold`——它们在 Geist 字体下显得突兀,破坏界面的"轻"感。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
|
||||
**禁止** `font-bold` / `font-semibold`——任务管理工具追求信息密度和"轻"感,加粗会破坏层次节奏。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"scripts": {
|
||||
"dev:web": "turbo dev --filter=@multica/web",
|
||||
"dev:desktop": "turbo dev --filter=@multica/desktop",
|
||||
"dev:desktop:remote": "pnpm --filter @multica/desktop dev:remote",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "turbo test",
|
||||
|
||||
@@ -32,4 +32,78 @@ describe("ApiClient", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the expected HTTP contract for autopilot endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
|
||||
new Response(JSON.stringify({ autopilots: [], runs: [], total: 0 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
|
||||
await client.listAutopilots({ status: "active" });
|
||||
await client.getAutopilot("ap-1");
|
||||
await client.createAutopilot({
|
||||
title: "Daily triage",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "create_issue",
|
||||
});
|
||||
await client.updateAutopilot("ap-1", { status: "paused" });
|
||||
await client.deleteAutopilot("ap-1");
|
||||
await client.triggerAutopilot("ap-1");
|
||||
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
|
||||
await client.createAutopilotTrigger("ap-1", {
|
||||
kind: "schedule",
|
||||
cron_expression: "0 9 * * *",
|
||||
timezone: "UTC",
|
||||
});
|
||||
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
||||
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
||||
|
||||
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
||||
url,
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body,
|
||||
}));
|
||||
|
||||
expect(calls).toMatchObject([
|
||||
{ url: "https://api.example.test/api/autopilots?status=active", method: "GET" },
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1", method: "GET" },
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Daily triage",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "create_issue",
|
||||
}),
|
||||
},
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1",
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "paused" }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/runs?limit=10&offset=20", method: "GET" },
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1/triggers",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
kind: "schedule",
|
||||
cron_expression: "0 9 * * *",
|
||||
timezone: "UTC",
|
||||
}),
|
||||
},
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1",
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
Attachment,
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
PendingChatTasksResponse,
|
||||
SendChatMessageResponse,
|
||||
Project,
|
||||
CreateProjectRequest,
|
||||
@@ -50,6 +52,17 @@ import type {
|
||||
CreatePinRequest,
|
||||
PinnedItemType,
|
||||
ReorderPinsRequest,
|
||||
Invitation,
|
||||
Autopilot,
|
||||
AutopilotTrigger,
|
||||
AutopilotRun,
|
||||
CreateAutopilotRequest,
|
||||
UpdateAutopilotRequest,
|
||||
CreateAutopilotTriggerRequest,
|
||||
UpdateAutopilotTriggerRequest,
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
@@ -521,6 +534,11 @@ export class ApiClient {
|
||||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getConfig(): Promise<{ cdn_domain: string }> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
|
||||
// Workspaces
|
||||
async listWorkspaces(): Promise<Workspace[]> {
|
||||
return this.fetch("/api/workspaces");
|
||||
@@ -549,7 +567,7 @@ export class ApiClient {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/members`);
|
||||
}
|
||||
|
||||
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<MemberWithUser> {
|
||||
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
@@ -575,6 +593,37 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Invitations
|
||||
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
|
||||
}
|
||||
|
||||
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async listMyInvitations(): Promise<Invitation[]> {
|
||||
return this.fetch("/api/invitations");
|
||||
}
|
||||
|
||||
async getInvitation(invitationId: string): Promise<Invitation> {
|
||||
return this.fetch(`/api/invitations/${invitationId}`);
|
||||
}
|
||||
|
||||
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
|
||||
return this.fetch(`/api/invitations/${invitationId}/accept`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async declineInvitation(invitationId: string): Promise<void> {
|
||||
await this.fetch(`/api/invitations/${invitationId}/decline`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWorkspace(workspaceId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: "DELETE",
|
||||
@@ -703,6 +752,18 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
||||
}
|
||||
|
||||
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
||||
return this.fetch(`/api/chat/pending-tasks`);
|
||||
}
|
||||
|
||||
async markChatSessionRead(sessionId: string): Promise<void> {
|
||||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
async cancelTaskById(taskId: string): Promise<void> {
|
||||
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||||
}
|
||||
@@ -768,4 +829,62 @@ export class ApiClient {
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Autopilots
|
||||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.status) search.set("status", params.status);
|
||||
return this.fetch(`/api/autopilots?${search}`);
|
||||
}
|
||||
|
||||
async getAutopilot(id: string): Promise<GetAutopilotResponse> {
|
||||
return this.fetch(`/api/autopilots/${id}`);
|
||||
}
|
||||
|
||||
async createAutopilot(data: CreateAutopilotRequest): Promise<Autopilot> {
|
||||
return this.fetch("/api/autopilots", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAutopilot(id: string, data: UpdateAutopilotRequest): Promise<Autopilot> {
|
||||
return this.fetch(`/api/autopilots/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAutopilot(id: string): Promise<void> {
|
||||
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async triggerAutopilot(id: string): Promise<AutopilotRun> {
|
||||
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
|
||||
}
|
||||
|
||||
async listAutopilotRuns(id: string, params?: { limit?: number; offset?: number }): Promise<ListAutopilotRunsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", params.limit.toString());
|
||||
if (params?.offset) search.set("offset", params.offset.toString());
|
||||
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
|
||||
}
|
||||
|
||||
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAutopilotTrigger(autopilotId: string, triggerId: string, data: UpdateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
|
||||
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
||||
10
packages/core/autopilots/index.ts
Normal file
10
packages/core/autopilots/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
|
||||
export {
|
||||
useCreateAutopilot,
|
||||
useUpdateAutopilot,
|
||||
useDeleteAutopilot,
|
||||
useTriggerAutopilot,
|
||||
useCreateAutopilotTrigger,
|
||||
useUpdateAutopilotTrigger,
|
||||
useDeleteAutopilotTrigger,
|
||||
} from "./mutations";
|
||||
130
packages/core/autopilots/mutations.ts
Normal file
130
packages/core/autopilots/mutations.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { autopilotKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type {
|
||||
CreateAutopilotRequest,
|
||||
UpdateAutopilotRequest,
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
CreateAutopilotTriggerRequest,
|
||||
UpdateAutopilotTriggerRequest,
|
||||
} from "../types";
|
||||
|
||||
export function useCreateAutopilot() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateAutopilotRequest) => api.createAutopilot(data),
|
||||
onSuccess: (newAutopilot) => {
|
||||
qc.setQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId), (old) =>
|
||||
old && !old.autopilots.some((a) => a.id === newAutopilot.id)
|
||||
? { ...old, autopilots: [...old.autopilots, newAutopilot], total: old.total + 1 }
|
||||
: old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAutopilot() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & UpdateAutopilotRequest) =>
|
||||
api.updateAutopilot(id, data),
|
||||
onMutate: ({ id, ...data }) => {
|
||||
qc.cancelQueries({ queryKey: autopilotKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<GetAutopilotResponse>(autopilotKeys.detail(wsId, id));
|
||||
qc.setQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId), (old) =>
|
||||
old ? { ...old, autopilots: old.autopilots.map((a) => (a.id === id ? { ...a, ...data } : a)) } : old,
|
||||
);
|
||||
qc.setQueryData<GetAutopilotResponse>(autopilotKeys.detail(wsId, id), (old) =>
|
||||
old ? { ...old, autopilot: { ...old.autopilot, ...data } } : old,
|
||||
);
|
||||
return { prevList, prevDetail, id };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(autopilotKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevDetail) qc.setQueryData(autopilotKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
||||
},
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAutopilot() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteAutopilot(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: autopilotKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId));
|
||||
qc.setQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId), (old) =>
|
||||
old ? { ...old, autopilots: old.autopilots.filter((a) => a.id !== id), total: old.total - 1 } : old,
|
||||
);
|
||||
qc.removeQueries({ queryKey: autopilotKeys.detail(wsId, id) });
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(autopilotKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTriggerAutopilot() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.triggerAutopilot(id),
|
||||
onSettled: (_data, _err, id) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, id) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAutopilotTrigger() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, ...data }: { autopilotId: string } & CreateAutopilotTriggerRequest) =>
|
||||
api.createAutopilotTrigger(autopilotId, data),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAutopilotTrigger() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, triggerId, ...data }: { autopilotId: string; triggerId: string } & UpdateAutopilotTriggerRequest) =>
|
||||
api.updateAutopilotTrigger(autopilotId, triggerId, data),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAutopilotTrigger() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, triggerId }: { autopilotId: string; triggerId: string }) =>
|
||||
api.deleteAutopilotTrigger(autopilotId, triggerId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
34
packages/core/autopilots/queries.ts
Normal file
34
packages/core/autopilots/queries.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const autopilotKeys = {
|
||||
all: (wsId: string) => ["autopilots", wsId] as const,
|
||||
list: (wsId: string) => [...autopilotKeys.all(wsId), "list"] as const,
|
||||
detail: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "detail", id] as const,
|
||||
runs: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "runs", id] as const,
|
||||
};
|
||||
|
||||
export function autopilotListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.list(wsId),
|
||||
queryFn: () => api.listAutopilots(),
|
||||
select: (data) => data.autopilots,
|
||||
});
|
||||
}
|
||||
|
||||
export function autopilotDetailOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.detail(wsId, id),
|
||||
queryFn: () => api.getAutopilot(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function autopilotRunsOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.runs(wsId, id),
|
||||
queryFn: () => api.listAutopilotRuns(id),
|
||||
select: (data) => data.runs,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
@@ -2,15 +2,67 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { chatKeys } from "./queries";
|
||||
import { createLogger } from "../logger";
|
||||
import type { ChatSession } from "../types";
|
||||
|
||||
const logger = createLogger("chat.mut");
|
||||
|
||||
export function useCreateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { agent_id: string; title?: string }) =>
|
||||
api.createChatSession(data),
|
||||
mutationFn: (data: { agent_id: string; title?: string }) => {
|
||||
logger.info("createChatSession.start", { agent_id: data.agent_id, titleLength: data.title?.length ?? 0 });
|
||||
return api.createChatSession(data);
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
logger.info("createChatSession.success", { sessionId: session.id, agentId: session.agent_id });
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error("createChatSession.error", err);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the session's unread state server-side. Optimistically flips
|
||||
* has_unread to false in the cached lists so the FAB badge drops
|
||||
* immediately. The server broadcasts chat:session_read so other devices
|
||||
* also sync.
|
||||
*/
|
||||
export function useMarkChatSessionRead() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("markChatSessionRead.start", { sessionId });
|
||||
return api.markChatSessionRead(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
|
||||
|
||||
const clear = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
|
||||
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
@@ -23,7 +75,10 @@ export function useArchiveChatSession() {
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("archiveChatSession.start", { sessionId });
|
||||
return api.archiveChatSession(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
@@ -41,13 +96,16 @@ export function useArchiveChatSession() {
|
||||
),
|
||||
);
|
||||
|
||||
logger.debug("archiveChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("archiveChatSession.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _err, sessionId) => {
|
||||
logger.debug("archiveChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
|
||||
@@ -14,6 +14,11 @@ export const chatKeys = {
|
||||
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
|
||||
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
|
||||
/** Per-task execution messages — shared with issue agent cards. */
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
};
|
||||
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
@@ -49,3 +54,44 @@ export function chatMessagesOptions(sessionId: string) {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending task for a chat session — the "is something still running?" signal.
|
||||
* Refetched via WS invalidation in useRealtimeSync when chat:message / chat:done
|
||||
* / task:completed / task:failed arrive.
|
||||
*/
|
||||
export function pendingChatTaskOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTask(sessionId),
|
||||
queryFn: () => api.getPendingChatTask(sessionId),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline for a single task — rendered by both the live chat view (while a
|
||||
* task is running) and AssistantMessage (for completed tasks). WS
|
||||
* `task:message` events seed this cache in real time via useRealtimeSync.
|
||||
*/
|
||||
export function taskMessagesOptions(taskId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.taskMessages(taskId),
|
||||
queryFn: () => api.listTaskMessages(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate of in-flight chat tasks for the current user in this workspace.
|
||||
* Drives the FAB "running" indicator while the chat window is minimised —
|
||||
* no per-session query is active then, so we need this roll-up.
|
||||
*/
|
||||
export function pendingChatTasksOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTasks(wsId),
|
||||
queryFn: () => api.listPendingChatTasks(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const logger = createLogger("chat.store");
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFT_KEY = "multica:chat:draft";
|
||||
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
|
||||
// Prune empty entries so the blob doesn't grow unbounded.
|
||||
const pruned: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(drafts)) {
|
||||
if (v) pruned[k] = v;
|
||||
}
|
||||
if (Object.keys(pruned).length === 0) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
storage.setItem(key, JSON.stringify(pruned));
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 420;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
* Kept as a public type because existing consumers (chat-message-list,
|
||||
* views/chat types) import it. Items themselves no longer live in the
|
||||
* store — they flow through the React Query cache keyed by task id.
|
||||
*/
|
||||
export interface ChatTimelineItem {
|
||||
seq: number;
|
||||
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
||||
@@ -26,11 +61,10 @@ export interface ChatTimelineItem {
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
pendingTaskId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
inputDraft: string;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
@@ -38,13 +72,11 @@ export interface ChatState {
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setPendingTask: (taskId: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
addTimelineItem: (item: ChatTimelineItem) => void;
|
||||
clearTimeline: () => void;
|
||||
setInputDraft: (draft: string) => void;
|
||||
clearInputDraft: () => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
@@ -62,20 +94,26 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
return wsId ? `${base}:${wsId}` : base;
|
||||
};
|
||||
|
||||
const store = create<ChatState>((set) => ({
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
isOpen: false,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
pendingTaskId: null,
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
timelineItems: [],
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
setOpen: (open) => set({ isOpen: open }),
|
||||
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
||||
setOpen: (open) => {
|
||||
logger.debug("setOpen", { from: get().isOpen, to: open });
|
||||
set({ isOpen: open });
|
||||
},
|
||||
toggle: () => {
|
||||
const next = !get().isOpen;
|
||||
logger.debug("toggle", { to: next });
|
||||
set({ isOpen: next });
|
||||
},
|
||||
setActiveSession: (id) => {
|
||||
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
|
||||
if (id) {
|
||||
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
|
||||
} else {
|
||||
@@ -83,35 +121,36 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}
|
||||
set({ activeSessionId: id });
|
||||
},
|
||||
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
|
||||
setSelectedAgentId: (id) => {
|
||||
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
setInputDraft: (draft) => {
|
||||
if (draft) {
|
||||
storage.setItem(wsKey(DRAFT_KEY), draft);
|
||||
} else {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
const next = { ...get().inputDrafts, [sessionId]: draft };
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
clearInputDraft: (sessionId) => {
|
||||
const current = get().inputDrafts;
|
||||
if (!(sessionId in current)) {
|
||||
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
|
||||
return;
|
||||
}
|
||||
set({ inputDraft: draft });
|
||||
logger.info("clearInputDraft", { sessionId });
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
clearInputDraft: () => {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
set({ inputDraft: "" });
|
||||
},
|
||||
addTimelineItem: (item) =>
|
||||
set((s) => {
|
||||
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
|
||||
return {
|
||||
timelineItems: [...s.timelineItems, item].sort(
|
||||
(a, b) => a.seq - b.seq,
|
||||
),
|
||||
};
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
storage.setItem(CHAT_HEIGHT_KEY, String(h));
|
||||
// Dragging = user chose a manual size → exit expanded mode
|
||||
@@ -119,6 +158,7 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
set({ chatWidth: w, chatHeight: h, isExpanded: false });
|
||||
},
|
||||
setExpanded: (expanded) => {
|
||||
logger.info("setExpanded", { to: expanded });
|
||||
if (expanded) {
|
||||
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
|
||||
} else {
|
||||
@@ -129,11 +169,20 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
|
||||
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
|
||||
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
|
||||
logger.info("workspace rehydration", {
|
||||
prevSession: store.getState().activeSessionId,
|
||||
nextSession,
|
||||
prevAgent: store.getState().selectedAgentId,
|
||||
nextAgent,
|
||||
draftCount: Object.keys(nextDrafts).length,
|
||||
});
|
||||
store.setState({
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
timelineItems: [],
|
||||
activeSessionId: nextSession,
|
||||
selectedAgentId: nextAgent,
|
||||
inputDrafts: nextDrafts,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
18
packages/core/config/index.ts
Normal file
18
packages/core/config/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
interface ConfigState {
|
||||
cdnDomain: string;
|
||||
setCdnDomain: (domain: string) => void;
|
||||
}
|
||||
|
||||
export const configStore = createStore<ConfigState>((set) => ({
|
||||
cdnDomain: "",
|
||||
setCdnDomain: (domain) => set({ cdnDomain: domain }),
|
||||
}));
|
||||
|
||||
export function useConfigStore(): ConfigState;
|
||||
export function useConfigStore<T>(selector: (state: ConfigState) => T): T;
|
||||
export function useConfigStore<T>(selector?: (state: ConfigState) => T) {
|
||||
return useStore(configStore, selector as (state: ConfigState) => T);
|
||||
}
|
||||
@@ -168,12 +168,21 @@ export function useUpdateIssue() {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
// Invalidate old parent's children cache
|
||||
if (ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, ctx.parentId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
// Invalidate new parent's children cache when parent_issue_id changed
|
||||
const newParentId = vars.parent_issue_id;
|
||||
if (newParentId && newParentId !== ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, newParentId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus } from "../../types";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
@@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20;
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
visitedAt: number;
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
|
||||
recordVisit: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (entry) =>
|
||||
recordVisit: (id) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== entry.id);
|
||||
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
|
||||
const filtered = state.items.filter((i) => i.id !== id);
|
||||
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
|
||||
@@ -29,16 +29,19 @@ export function onIssueUpdated(
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
) {
|
||||
// Look up the parent before mutating list state, so we can also keep the
|
||||
// parent's children cache in sync (powers the sub-issues list shown on
|
||||
// the parent issue page).
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
// shown on the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const parentId =
|
||||
issue.parent_issue_id ??
|
||||
const oldParentId =
|
||||
detailData?.parent_issue_id ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
null;
|
||||
// The NEW parent comes from the WS payload when parent_issue_id changed
|
||||
const newParentId = issue.parent_issue_id ?? null;
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
@@ -63,10 +66,22 @@ export function onIssueUpdated(
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
|
||||
// Invalidate old parent's children (issue was removed from it)
|
||||
if (oldParentId) {
|
||||
if (parentChanged) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
|
||||
} else {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Invalidate new parent's children (issue was added to it)
|
||||
if (newParentId && parentChanged) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
|
||||
}
|
||||
if (oldParentId || newParentId) {
|
||||
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createPersistStorage } from "../platform/persist-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
|
||||
const EXCLUDED_PREFIXES = ["/login", "/pair/", "/invite/"];
|
||||
|
||||
interface NavigationState {
|
||||
lastPath: string;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"./api": "./api/index.ts",
|
||||
"./api/client": "./api/client.ts",
|
||||
"./api/ws-client": "./api/ws-client.ts",
|
||||
"./config": "./config/index.ts",
|
||||
"./auth": "./auth/index.ts",
|
||||
"./workspace": "./workspace/index.ts",
|
||||
"./workspace/queries": "./workspace/queries.ts",
|
||||
@@ -45,6 +46,9 @@
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
"./projects/config": "./projects/config.ts",
|
||||
"./autopilots": "./autopilots/index.ts",
|
||||
"./autopilots/queries": "./autopilots/queries.ts",
|
||||
"./autopilots/mutations": "./autopilots/mutations.ts",
|
||||
"./pins": "./pins/index.ts",
|
||||
"./pins/queries": "./pins/queries.ts",
|
||||
"./pins/mutations": "./pins/mutations.ts",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { configStore } from "../config";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
@@ -31,6 +32,11 @@ export function AuthInitializer({
|
||||
const api = getApi();
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
|
||||
// Fetch app config (CDN domain, etc.) in the background — non-blocking.
|
||||
api.getConfig().then((cfg) => {
|
||||
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
|
||||
}).catch(() => { /* config is optional — legacy file card matching degrades gracefully */ });
|
||||
|
||||
if (cookieAuth) {
|
||||
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
|
||||
// Call the API to check if the session is still valid.
|
||||
|
||||
@@ -17,6 +17,8 @@ describe("clearWorkspaceStorage", () => {
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:drafts:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:expanded:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ const WORKSPACE_SCOPED_KEYS = [
|
||||
"multica_my_issues_view",
|
||||
"multica:chat:selectedAgentId",
|
||||
"multica:chat:activeSessionId",
|
||||
"multica:chat:drafts",
|
||||
"multica:chat:expanded",
|
||||
];
|
||||
|
||||
/** Remove all workspace-scoped storage entries for the given workspace. */
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defaultStorage } from "../platform/storage";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { projectKeys } from "../projects/queries";
|
||||
import { pinKeys } from "../pins/queries";
|
||||
import { autopilotKeys } from "../autopilots/queries";
|
||||
import { runtimeKeys } from "../runtimes/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -39,8 +41,15 @@ import type {
|
||||
IssueReactionRemovedPayload,
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
ChatDonePayload,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
|
||||
const chatWsLogger = createLogger("chat.ws");
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
|
||||
export interface RealtimeSyncStores {
|
||||
@@ -109,6 +118,10 @@ export function useRealtimeSync(
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
},
|
||||
autopilot: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
},
|
||||
};
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -133,6 +146,9 @@ export function useRealtimeSync(
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat / task events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
"task:message", "task:completed", "task:failed",
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
@@ -276,13 +292,139 @@ export function useRealtimeSync(
|
||||
const myUserId = authStore.getState().user?.id;
|
||||
if (member.user_id === myUserId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
onToast?.(
|
||||
`You were invited to ${workspace_name ?? "a workspace"}`,
|
||||
`You joined ${workspace_name ?? "a workspace"}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// invitation:created — notify the invitee of a new pending invitation
|
||||
const unsubInvitationCreated = ws.on("invitation:created", (p) => {
|
||||
const { workspace_name } = p as InvitationCreatedPayload;
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
onToast?.(
|
||||
`You were invited to ${workspace_name ?? "a workspace"}`,
|
||||
"info",
|
||||
);
|
||||
});
|
||||
|
||||
// invitation:accepted / declined / revoked — refresh invitation lists
|
||||
const unsubInvitationAccepted = ws.on("invitation:accepted", () => {
|
||||
const currentWsId = workspaceStore.getState().workspace?.id;
|
||||
if (currentWsId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(currentWsId) });
|
||||
}
|
||||
});
|
||||
const unsubInvitationDeclined = ws.on("invitation:declined", () => {
|
||||
const currentWsId = workspaceStore.getState().workspace?.id;
|
||||
if (currentWsId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
|
||||
}
|
||||
});
|
||||
const unsubInvitationRevoked = ws.on("invitation:revoked", () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
});
|
||||
|
||||
// --- Chat / task events (global, survives ChatWindow unmount) ---
|
||||
//
|
||||
// Single source of truth: the Query cache. No Zustand writes here — the
|
||||
// earlier mirror caused a race where the cache and store disagreed
|
||||
// during the invalidate → refetch window and the UI rendered duplicates.
|
||||
//
|
||||
// task:message is written directly into the task-messages cache so the
|
||||
// live timeline updates in place. chat:message / chat:done /
|
||||
// task:completed / task:failed invalidate messages + pending-task so the
|
||||
// DB remains authoritative.
|
||||
|
||||
const unsubTaskMessage = ws.on("task:message", (p) => {
|
||||
const payload = p as TaskMessagePayload;
|
||||
qc.setQueryData<TaskMessagePayload[]>(
|
||||
["task-messages", payload.task_id],
|
||||
(old = []) => {
|
||||
if (old.some((m) => m.seq === payload.seq)) return old;
|
||||
return [...old, payload].sort((a, b) => a.seq - b.seq);
|
||||
},
|
||||
);
|
||||
chatWsLogger.debug("task:message (global)", {
|
||||
task_id: payload.task_id,
|
||||
seq: payload.seq,
|
||||
type: payload.type,
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers reused by chat lifecycle handlers.
|
||||
const invalidatePendingAggregate = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) qc.invalidateQueries({ queryKey: chatKeys.pendingTasks(id) });
|
||||
};
|
||||
const invalidateSessionLists = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
|
||||
}
|
||||
};
|
||||
|
||||
const unsubChatMessage = ws.on("chat:message", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:message (global)", { chat_session_id: payload.chat_session_id });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatDone = ws.on("chat:done", (p) => {
|
||||
const payload = p as ChatDonePayload;
|
||||
chatWsLogger.info("chat:done (global)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// Assistant message was just written and task flipped out of 'running'.
|
||||
// Clear pending-task cache immediately so the live-timeline-vs-assistant
|
||||
// race window collapses to zero — the subsequent refetch will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
// Assistant message just landed → has_unread may have flipped to true.
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
const unsubTaskCompleted = ws.on("task:completed", (p) => {
|
||||
const payload = p as TaskCompletedPayload;
|
||||
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
|
||||
chatWsLogger.info("task:completed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubTaskFailed = ws.on("task:failed", (p) => {
|
||||
const payload = p as TaskFailedPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
chatWsLogger.warn("task:failed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// No new message; just flip the pending signal.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatSessionRead = ws.on("chat:session_read", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:session_read (global)", payload);
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
@@ -302,6 +444,16 @@ export function useRealtimeSync(
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
unsubInvitationCreated();
|
||||
unsubInvitationAccepted();
|
||||
unsubInvitationDeclined();
|
||||
unsubInvitationRevoked();
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
@@ -323,6 +475,7 @@ export function useRealtimeSync(
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
} catch (e) {
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface Agent {
|
||||
avatar_url: string | null;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
runtime_config: Record<string, unknown>;
|
||||
custom_env: Record<string, string>;
|
||||
custom_env_redacted: boolean;
|
||||
visibility: AgentVisibility;
|
||||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
@@ -65,6 +67,7 @@ export interface CreateAgentRequest {
|
||||
avatar_url?: string;
|
||||
runtime_id: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
}
|
||||
@@ -76,6 +79,7 @@ export interface UpdateAgentRequest {
|
||||
avatar_url?: string;
|
||||
runtime_id?: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
|
||||
108
packages/core/types/autopilot.ts
Normal file
108
packages/core/types/autopilot.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export type AutopilotStatus = "active" | "paused" | "archived";
|
||||
|
||||
export type AutopilotExecutionMode = "create_issue" | "run_only";
|
||||
|
||||
export type AutopilotTriggerKind = "schedule" | "webhook" | "api";
|
||||
|
||||
export type AutopilotRunStatus = "issue_created" | "running" | "completed" | "failed";
|
||||
|
||||
export type AutopilotRunSource = "schedule" | "manual" | "webhook" | "api";
|
||||
|
||||
export interface Autopilot {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
assignee_id: string;
|
||||
priority: string;
|
||||
status: AutopilotStatus;
|
||||
execution_mode: AutopilotExecutionMode;
|
||||
issue_title_template: string | null;
|
||||
created_by_type: string;
|
||||
created_by_id: string;
|
||||
last_run_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AutopilotTrigger {
|
||||
id: string;
|
||||
autopilot_id: string;
|
||||
kind: AutopilotTriggerKind;
|
||||
enabled: boolean;
|
||||
cron_expression: string | null;
|
||||
timezone: string | null;
|
||||
next_run_at: string | null;
|
||||
webhook_token: string | null;
|
||||
label: string | null;
|
||||
last_fired_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AutopilotRun {
|
||||
id: string;
|
||||
autopilot_id: string;
|
||||
trigger_id: string | null;
|
||||
source: AutopilotRunSource;
|
||||
status: AutopilotRunStatus;
|
||||
issue_id: string | null;
|
||||
task_id: string | null;
|
||||
triggered_at: string;
|
||||
completed_at: string | null;
|
||||
failure_reason: string | null;
|
||||
trigger_payload: unknown;
|
||||
result: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAutopilotRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
assignee_id: string;
|
||||
project_id?: string;
|
||||
priority?: string;
|
||||
execution_mode: AutopilotExecutionMode;
|
||||
issue_title_template?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAutopilotRequest {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
assignee_id?: string;
|
||||
project_id?: string | null;
|
||||
priority?: string;
|
||||
status?: AutopilotStatus;
|
||||
execution_mode?: AutopilotExecutionMode;
|
||||
issue_title_template?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAutopilotTriggerRequest {
|
||||
kind: AutopilotTriggerKind;
|
||||
cron_expression?: string;
|
||||
timezone?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAutopilotTriggerRequest {
|
||||
enabled?: boolean;
|
||||
cron_expression?: string;
|
||||
timezone?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ListAutopilotsResponse {
|
||||
autopilots: Autopilot[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GetAutopilotResponse {
|
||||
autopilot: Autopilot;
|
||||
triggers: AutopilotTrigger[];
|
||||
}
|
||||
|
||||
export interface ListAutopilotRunsResponse {
|
||||
runs: AutopilotRun[];
|
||||
total: number;
|
||||
}
|
||||
@@ -5,10 +5,22 @@ export interface ChatSession {
|
||||
creator_id: string;
|
||||
title: string;
|
||||
status: "active" | "archived";
|
||||
/** True when the session has any unread assistant replies. List-only. */
|
||||
has_unread: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTaskItem {
|
||||
task_id: string;
|
||||
status: string;
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTasksResponse {
|
||||
tasks: PendingChatTaskItem[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
chat_session_id: string;
|
||||
@@ -22,3 +34,12 @@ export interface SendChatMessageResponse {
|
||||
message_id: string;
|
||||
task_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/chat/sessions/{id}/pending-task.
|
||||
* Both fields are absent when the session has no in-flight task.
|
||||
*/
|
||||
export interface ChatPendingTask {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Agent } from "./agent";
|
||||
import type { InboxItem } from "./inbox";
|
||||
import type { Comment, Reaction } from "./comment";
|
||||
import type { TimelineEntry } from "./activity";
|
||||
import type { Workspace, MemberWithUser } from "./workspace";
|
||||
import type { Workspace, MemberWithUser, Invitation } from "./workspace";
|
||||
import type { Project } from "./project";
|
||||
|
||||
// WebSocket event types (matching Go server protocol/events.go)
|
||||
@@ -48,11 +48,16 @@ export type WSEventType =
|
||||
| "issue_reaction:removed"
|
||||
| "chat:message"
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
| "pin:created"
|
||||
| "pin:deleted";
|
||||
| "pin:deleted"
|
||||
| "invitation:created"
|
||||
| "invitation:accepted"
|
||||
| "invitation:declined"
|
||||
| "invitation:revoked";
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
@@ -170,6 +175,7 @@ export interface ActivityCreatedPayload {
|
||||
export interface TaskMessagePayload {
|
||||
task_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
seq: number;
|
||||
type: "text" | "thinking" | "tool_use" | "tool_result" | "error";
|
||||
tool?: string;
|
||||
@@ -182,6 +188,7 @@ export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -189,6 +196,7 @@ export interface TaskFailedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -196,6 +204,7 @@ export interface TaskCancelledPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -239,6 +248,10 @@ export interface ChatDonePayload {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionReadPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreatedPayload {
|
||||
project: Project;
|
||||
}
|
||||
@@ -250,3 +263,23 @@ export interface ProjectUpdatedPayload {
|
||||
export interface ProjectDeletedPayload {
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
export interface InvitationCreatedPayload {
|
||||
invitation: Invitation;
|
||||
workspace_name?: string;
|
||||
}
|
||||
|
||||
export interface InvitationAcceptedPayload {
|
||||
invitation_id: string;
|
||||
member: MemberWithUser;
|
||||
}
|
||||
|
||||
export interface InvitationDeclinedPayload {
|
||||
invitation_id: string;
|
||||
invitee_email: string;
|
||||
}
|
||||
|
||||
export interface InvitationRevokedPayload {
|
||||
invitation_id: string;
|
||||
invitee_email: string;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export type {
|
||||
RuntimeUpdateStatus,
|
||||
IssueUsageSummary,
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
@@ -30,7 +30,24 @@ export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
|
||||
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
AutopilotExecutionMode,
|
||||
AutopilotTrigger,
|
||||
AutopilotTriggerKind,
|
||||
AutopilotRun,
|
||||
AutopilotRunStatus,
|
||||
AutopilotRunSource,
|
||||
CreateAutopilotRequest,
|
||||
UpdateAutopilotRequest,
|
||||
CreateAutopilotTriggerRequest,
|
||||
UpdateAutopilotTriggerRequest,
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
} from "./autopilot";
|
||||
|
||||
@@ -45,3 +45,19 @@ export interface MemberWithUser {
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
inviter_id: string;
|
||||
invitee_email: string;
|
||||
invitee_user_id: string | null;
|
||||
role: MemberRole;
|
||||
status: "pending" | "accepted" | "declined" | "expired";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string;
|
||||
inviter_name?: string;
|
||||
inviter_email?: string;
|
||||
workspace_name?: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export const workspaceKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId] as const,
|
||||
list: () => ["workspaces", "list"] as const,
|
||||
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
|
||||
invitations: (wsId: string) => ["workspaces", wsId, "invitations"] as const,
|
||||
myInvitations: () => ["invitations", "mine"] as const,
|
||||
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
|
||||
@@ -39,6 +41,20 @@ export function skillListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function invitationListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.invitations(wsId),
|
||||
queryFn: () => api.listWorkspaceInvitations(wsId),
|
||||
});
|
||||
}
|
||||
|
||||
export function myInvitationListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.myInvitations(),
|
||||
queryFn: () => api.listMyInvitations(),
|
||||
});
|
||||
}
|
||||
|
||||
export function assigneeFrequencyOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.assigneeFrequency(wsId),
|
||||
|
||||
@@ -3,8 +3,10 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FileText, Download } from 'lucide-react'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessFileCards } from './file-cards'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { preprocessMentionShortcodes } from './mentions'
|
||||
|
||||
@@ -48,6 +50,11 @@ export interface MarkdownProps {
|
||||
* When not provided, mentions render as a simple styled span.
|
||||
*/
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode
|
||||
/**
|
||||
* CDN hostname for file card detection (e.g. "multica-static.copilothub.ai").
|
||||
* When provided, enables file card preprocessing and rendering.
|
||||
*/
|
||||
cdnDomain?: string
|
||||
}
|
||||
|
||||
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
|
||||
@@ -60,6 +67,12 @@ const sanitizeSchema = {
|
||||
},
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: [
|
||||
...(defaultSchema.attributes?.div ?? []),
|
||||
'dataType',
|
||||
'dataHref',
|
||||
'dataFilename',
|
||||
],
|
||||
code: [
|
||||
...(defaultSchema.attributes?.code ?? []),
|
||||
['className', /^language-/],
|
||||
@@ -93,9 +106,37 @@ function createComponents(
|
||||
mode: RenderMode,
|
||||
onUrlClick?: (url: string) => void,
|
||||
onFileClick?: (path: string) => void,
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode,
|
||||
): Partial<Components> {
|
||||
const baseComponents: Partial<Components> = {
|
||||
// FileCard: intercept <div data-type="fileCard"> from preprocessFileCards
|
||||
div: ({ node, children, ...props }) => {
|
||||
const dataType = node?.properties?.dataType as string | undefined
|
||||
if (dataType === 'fileCard') {
|
||||
const rawHref = (node?.properties?.dataHref as string) || ''
|
||||
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
|
||||
const href = /^https?:\/\//i.test(rawHref) ? rawHref : ''
|
||||
const filename = (node?.properties?.dataFilename as string) || ''
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{filename}</p>
|
||||
</div>
|
||||
{href && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
onClick={() => window.open(href, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div {...props}>{children}</div>
|
||||
},
|
||||
// Images: render uploaded images with constrained sizing
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
@@ -115,7 +156,11 @@ function createComponents(
|
||||
const id = mentionMatch[2]
|
||||
|
||||
if (renderMention) {
|
||||
return <>{renderMention({ type, id })}</>
|
||||
// Let the custom renderer opt out for types it doesn't handle
|
||||
// by returning null/undefined — we then fall through to the
|
||||
// default styled span so nothing ever disappears silently.
|
||||
const rendered = renderMention({ type, id })
|
||||
if (rendered) return <>{rendered}</>
|
||||
}
|
||||
|
||||
// Fallback: render as a simple styled span
|
||||
@@ -333,17 +378,23 @@ export function Markdown({
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick,
|
||||
renderMention
|
||||
renderMention,
|
||||
cdnDomain
|
||||
}: MarkdownProps): React.JSX.Element {
|
||||
const components = React.useMemo(
|
||||
() => createComponents(mode, onUrlClick, onFileClick, renderMention),
|
||||
[mode, onUrlClick, onFileClick, renderMention]
|
||||
)
|
||||
|
||||
// Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links
|
||||
// Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content
|
||||
const processedContent = React.useMemo(
|
||||
() => preprocessLinks(preprocessMentionShortcodes(children)),
|
||||
[children]
|
||||
() => {
|
||||
let result = preprocessMentionShortcodes(children)
|
||||
result = preprocessLinks(result)
|
||||
result = preprocessFileCards(result, cdnDomain ?? '')
|
||||
return result
|
||||
},
|
||||
[children, cdnDomain]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface StreamingMarkdownProps {
|
||||
onUrlClick?: (url: string) => void
|
||||
onFileClick?: (path: string) => void
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode
|
||||
cdnDomain?: string
|
||||
}
|
||||
|
||||
interface Block {
|
||||
@@ -136,7 +137,8 @@ const MemoizedBlock = React.memo(
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick,
|
||||
renderMention
|
||||
renderMention,
|
||||
cdnDomain
|
||||
}: {
|
||||
content: string
|
||||
mode: RenderMode
|
||||
@@ -144,9 +146,10 @@ const MemoizedBlock = React.memo(
|
||||
onUrlClick?: (url: string) => void
|
||||
onFileClick?: (path: string) => void
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode
|
||||
cdnDomain?: string
|
||||
}) {
|
||||
return (
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention}>
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention} cdnDomain={cdnDomain}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
@@ -181,7 +184,8 @@ export function StreamingMarkdown({
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick,
|
||||
renderMention
|
||||
renderMention,
|
||||
cdnDomain
|
||||
}: StreamingMarkdownProps): React.JSX.Element {
|
||||
// Split into blocks - memoized to avoid recomputation
|
||||
// Must be called unconditionally to satisfy Rules of Hooks
|
||||
@@ -193,7 +197,7 @@ export function StreamingMarkdown({
|
||||
// Not streaming - use simple Markdown (no block splitting needed)
|
||||
if (!isStreaming) {
|
||||
return (
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention}>
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention} cdnDomain={cdnDomain}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
@@ -222,6 +226,7 @@ export function StreamingMarkdown({
|
||||
onUrlClick={onUrlClick}
|
||||
onFileClick={onFileClick}
|
||||
renderMention={renderMention}
|
||||
cdnDomain={cdnDomain}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
89
packages/ui/markdown/file-cards.ts
Normal file
89
packages/ui/markdown/file-cards.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* File card preprocessing for markdown content.
|
||||
*
|
||||
* Converts file-card syntax into HTML divs that can be rendered by
|
||||
* react-markdown with a custom `div` component.
|
||||
*
|
||||
* Two syntaxes are supported:
|
||||
* 1. `!file[name](url)` — new unambiguous syntax (no hostname check needed)
|
||||
* 2. `[name](cdnUrl)` — legacy syntax, matched by CDN hostname on own line
|
||||
*
|
||||
* Output: `<div data-type="fileCard" data-href="url" data-filename="name"></div>`
|
||||
*
|
||||
* All functions are pure — no global state, no imports from core/ or views/.
|
||||
*/
|
||||
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i
|
||||
|
||||
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */
|
||||
const NEW_FILE_CARD_RE = /^!file\[([^\]]*)\]\((https?:\/\/[^)]+)\)$/
|
||||
|
||||
/** Legacy syntax: [name](cdnUrl) on its own line — matched by CDN hostname. */
|
||||
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
}
|
||||
|
||||
function toFileCardHtml(filename: string, url: string): string {
|
||||
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL points to our upload CDN.
|
||||
*
|
||||
* Uses exact hostname match against `cdnDomain` (e.g. "multica-static.copilothub.ai"),
|
||||
* and also matches any `.amazonaws.com` subdomain as a fallback for direct S3 URLs.
|
||||
*/
|
||||
export function isCdnUrl(url: string, cdnDomain: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.hostname === cdnDomain || u.hostname.endsWith('.amazonaws.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a CDN URL is a non-image file that should render as a file card.
|
||||
* Image URLs (png, jpg, etc.) are excluded — they render as inline images.
|
||||
*/
|
||||
export function isFileCardUrl(url: string, cdnDomain: string): boolean {
|
||||
try {
|
||||
return isCdnUrl(url, cdnDomain) && !IMAGE_EXTS.test(new URL(url).pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess markdown to convert file-card syntax into HTML divs.
|
||||
*
|
||||
* Handles both `!file[name](url)` (new syntax) and legacy `[name](cdnUrl)`
|
||||
* lines. Only standalone lines are matched — inline links are left untouched.
|
||||
*
|
||||
* @param markdown Raw markdown string
|
||||
* @param cdnDomain CDN hostname for legacy link detection (e.g. "multica-static.copilothub.ai")
|
||||
*/
|
||||
export function preprocessFileCards(markdown: string, cdnDomain: string): string {
|
||||
return markdown
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim()
|
||||
|
||||
// New syntax: !file[name](url) — always a file card, no hostname check needed.
|
||||
const newMatch = trimmed.match(NEW_FILE_CARD_RE)
|
||||
if (newMatch) {
|
||||
return toFileCardHtml(newMatch[1]!, newMatch[2]!)
|
||||
}
|
||||
|
||||
// Legacy: [name](cdnUrl) on its own line — CDN hostname matching.
|
||||
const match = trimmed.match(FILE_LINK_LINE)
|
||||
if (!match) return line
|
||||
const filename = match[1]!
|
||||
const url = match[2]!
|
||||
if (!isFileCardUrl(url, cdnDomain)) return line
|
||||
return toFileCardHtml(filename, url)
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
|
||||
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
|
||||
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
|
||||
export { preprocessMentionShortcodes } from './mentions'
|
||||
export { preprocessFileCards, isCdnUrl, isFileCardUrl } from './file-cards'
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Chat FAB: gentle color + border tint while a chat task is running.
|
||||
* Keeps the ring at the same thickness — only hue shifts towards brand
|
||||
* at half-cycle, no outer glow. */
|
||||
@keyframes chat-impulse {
|
||||
0%, 100% {
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||
}
|
||||
50% {
|
||||
color: var(--brand);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--brand) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-chat-impulse {
|
||||
animation: chat-impulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
@@ -55,5 +73,9 @@
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
/* Auto-insert 1/4em space between CJK ideographs and Latin letters/numerals.
|
||||
* Native CSS text-autospace (Chrome 119+, Electron recent versions).
|
||||
* Progressive enhancement: browsers that don't support it simply ignore the rule. */
|
||||
text-autospace: ideograph-alpha ideograph-numeric;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import type { Agent, RuntimeDevice } from "@multica/core/types";
|
||||
import type { Agent, RuntimeDevice, MemberWithUser } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -34,29 +35,35 @@ import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { TasksTab } from "./tabs/tasks-tab";
|
||||
import { SettingsTab } from "./tabs/settings-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
|
||||
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
|
||||
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
|
||||
}
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
|
||||
|
||||
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
||||
{ id: "env", label: "Environment", icon: KeyRound },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function AgentDetail({
|
||||
agent,
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
onUpdate,
|
||||
onArchive,
|
||||
onRestore,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
||||
onArchive: (id: string) => Promise<void>;
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
@@ -158,10 +165,19 @@ export function AgentDetail({
|
||||
<SkillsTab agent={agent} />
|
||||
)}
|
||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||
{activeTab === "env" && (
|
||||
<EnvTab
|
||||
agent={agent}
|
||||
readOnly={agent.custom_env_redacted}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "settings" && (
|
||||
<SettingsTab
|
||||
agent={agent}
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUserId}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -17,13 +17,14 @@ import { useAuthStore } from "@multica/core/auth";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { agentListOptions, memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
import { AgentListItem } from "./agent-list-item";
|
||||
import { AgentDetail } from "./agent-detail";
|
||||
|
||||
export function AgentsPage() {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
@@ -31,6 +32,7 @@ export function AgentsPage() {
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const { data: runtimes = [], isLoading: runtimesLoading } = useQuery(runtimeListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_agents_layout",
|
||||
});
|
||||
@@ -201,6 +203,8 @@ export function AgentsPage() {
|
||||
key={selected.id}
|
||||
agent={selected}
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
onUpdate={handleUpdate}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
@@ -225,6 +229,8 @@ export function AgentsPage() {
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
runtimesLoading={runtimesLoading}
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Cloud, ChevronDown, Globe, Lock, Loader2 } from "lucide-react";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import type {
|
||||
AgentVisibility,
|
||||
RuntimeDevice,
|
||||
MemberWithUser,
|
||||
CreateAgentRequest,
|
||||
} from "@multica/core/types";
|
||||
import {
|
||||
@@ -26,29 +28,55 @@ import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
|
||||
export function CreateAgentDialog({
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
members,
|
||||
currentUserId,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
runtimes: RuntimeDevice[];
|
||||
runtimesLoading?: boolean;
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
onClose: () => void;
|
||||
onCreate: (data: CreateAgentRequest) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? "");
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>("private");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
|
||||
|
||||
const getOwnerMember = (ownerId: string | null) => {
|
||||
if (!ownerId) return null;
|
||||
return members.find((m) => m.user_id === ownerId) ?? null;
|
||||
};
|
||||
|
||||
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
|
||||
|
||||
const filteredRuntimes = useMemo(() => {
|
||||
const filtered = runtimeFilter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
|
||||
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [runtimes, runtimeFilter, currentUserId]);
|
||||
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(filteredRuntimes[0]?.id ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRuntimeId && runtimes[0]) {
|
||||
setSelectedRuntimeId(runtimes[0].id);
|
||||
if (!selectedRuntimeId && filteredRuntimes[0]) {
|
||||
setSelectedRuntimeId(filteredRuntimes[0].id);
|
||||
}
|
||||
}, [runtimes, selectedRuntimeId]);
|
||||
}, [filteredRuntimes, selectedRuntimeId]);
|
||||
|
||||
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
|
||||
|
||||
@@ -141,7 +169,35 @@ export function CreateAgentDialog({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
{hasOtherRuntimes && (
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRuntimeFilter("mine"); setSelectedRuntimeId(""); }}
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
||||
runtimeFilter === "mine"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Mine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRuntimeFilter("all"); setSelectedRuntimeId(""); }}
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
||||
runtimeFilter === "all"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={runtimes.length === 0 && !runtimesLoading}
|
||||
@@ -166,42 +222,56 @@ export function CreateAgentDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{selectedRuntime?.device_info ?? "Register a runtime before creating an agent"}
|
||||
{selectedRuntime
|
||||
? (getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info)
|
||||
: "Register a runtime before creating an agent"}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
|
||||
{runtimes.map((device) => (
|
||||
<button
|
||||
key={device.id}
|
||||
onClick={() => {
|
||||
setSelectedRuntimeId(device.id);
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
{filteredRuntimes.map((device) => {
|
||||
const ownerMember = getOwnerMember(device.owner_id);
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
onClick={() => {
|
||||
setSelectedRuntimeId(device.id);
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ownerMember ? (
|
||||
<>
|
||||
<ActorAvatar actorType="member" actorId={ownerMember.user_id} size={14} />
|
||||
<span className="truncate">{ownerMember.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{device.device_info}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
233
packages/views/agents/components/tabs/env-tab.tsx
Normal file
233
packages/views/agents/components/tabs/env-tab.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
interface EnvEntry {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
|
||||
return Object.entries(env).map(([key, value]) => ({
|
||||
id: nextEnvId++,
|
||||
key,
|
||||
value,
|
||||
visible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const key = entry.key.trim();
|
||||
if (key) {
|
||||
map[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function EnvTab({
|
||||
agent,
|
||||
readOnly = false,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
readOnly?: boolean;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
|
||||
envMapToEntries(agent.custom_env ?? {}),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentEnvMap = entriesToEnvMap(envEntries);
|
||||
const originalEnvMap = agent.custom_env ?? {};
|
||||
const dirty =
|
||||
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
|
||||
|
||||
const addEnvEntry = () => {
|
||||
setEnvEntries([
|
||||
...envEntries,
|
||||
{ id: nextEnvId++, key: "", value: "", visible: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEnvEntry = (index: number) => {
|
||||
setEnvEntries(envEntries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEnvEntry = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
val: string,
|
||||
) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEnvVisibility = (index: number) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, visible: !entry.visible } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
|
||||
const uniqueKeys = new Set(keys);
|
||||
if (uniqueKeys.size < keys.length) {
|
||||
toast.error("Duplicate environment variable keys");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ custom_env: currentEnvMap });
|
||||
toast.success("Environment variables saved");
|
||||
} catch {
|
||||
toast.error("Failed to save environment variables");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Injected into the agent process at launch. Values are hidden — only the agent owner or workspace admin can view and edit them.
|
||||
</p>
|
||||
</div>
|
||||
{envEntries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{envEntries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
readOnly
|
||||
className="w-[40%] font-mono text-xs bg-muted"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="password"
|
||||
value="****"
|
||||
readOnly
|
||||
className="pr-8 font-mono text-xs bg-muted"
|
||||
/>
|
||||
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">No environment variables configured.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_BASE_URL)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEnvEntry}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{envEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{envEntries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="w-[40%] font-mono text-xs"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={entry.visible ? "text" : "password"}
|
||||
value={entry.value}
|
||||
onChange={(e) =>
|
||||
updateEnvEntry(index, "value", e.target.value)
|
||||
}
|
||||
placeholder="value"
|
||||
className="pr-8 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnvVisibility(index)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{entry.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvEntry(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useRef, useMemo } from "react";
|
||||
import {
|
||||
Cloud,
|
||||
Monitor,
|
||||
Loader2,
|
||||
Save,
|
||||
Globe,
|
||||
@@ -11,7 +9,7 @@ import {
|
||||
Camera,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import type { Agent, AgentVisibility, RuntimeDevice } from "@multica/core/types";
|
||||
import type { Agent, AgentVisibility, RuntimeDevice, MemberWithUser } from "@multica/core/types";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
@@ -24,14 +22,21 @@ import { toast } from "sonner";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
|
||||
export function SettingsTab({
|
||||
agent,
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(agent.name);
|
||||
@@ -40,11 +45,31 @@ export function SettingsTab({
|
||||
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(agent.runtime_id);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getOwnerMember = (ownerId: string | null) => {
|
||||
if (!ownerId) return null;
|
||||
return members.find((m) => m.user_id === ownerId) ?? null;
|
||||
};
|
||||
|
||||
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
|
||||
|
||||
const filteredRuntimes = useMemo(() => {
|
||||
const filtered = runtimeFilter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
|
||||
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [runtimes, runtimeFilter, currentUserId]);
|
||||
|
||||
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
|
||||
const selectedOwnerMember = selectedRuntime ? getOwnerMember(selectedRuntime.owner_id) : null;
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -72,6 +97,7 @@ export function SettingsTab({
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
@@ -190,16 +216,44 @@ export function SettingsTab({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
{hasOtherRuntimes && (
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRuntimeFilter("mine")}
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
||||
runtimeFilter === "mine"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Mine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRuntimeFilter("all")}
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
|
||||
runtimeFilter === "all"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={runtimes.length === 0}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{selectedRuntime?.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{selectedRuntime ? (
|
||||
<ProviderLogo provider={selectedRuntime.provider} className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ProviderLogo provider="" className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -213,46 +267,56 @@ export function SettingsTab({
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{selectedRuntime?.device_info ?? "Select a runtime"}
|
||||
{selectedRuntime ? (
|
||||
selectedOwnerMember ? selectedOwnerMember.name : selectedRuntime.device_info
|
||||
) : "Select a runtime"}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
|
||||
{runtimes.map((device) => (
|
||||
<button
|
||||
key={device.id}
|
||||
onClick={() => {
|
||||
setSelectedRuntimeId(device.id);
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
{device.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
{filteredRuntimes.map((device) => {
|
||||
const ownerMember = getOwnerMember(device.owner_id);
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
onClick={() => {
|
||||
setSelectedRuntimeId(device.id);
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ownerMember ? (
|
||||
<>
|
||||
<ActorAvatar actorType="member" actorId={ownerMember.user_id} size={14} />
|
||||
<span className="truncate">{ownerMember.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{device.device_info}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, FileText, Trash2 } from "lucide-react";
|
||||
import { Plus, FileText, Trash2, Info } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -65,7 +65,7 @@ export function SkillsTab({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Skills</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Reusable skills assigned to this agent. Manage skills on the Skills page.
|
||||
Workspace skills assigned to this agent.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -79,12 +79,19 @@ export function SkillsTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-info/20 bg-info/5 px-3 py-2.5">
|
||||
<Info className="h-3.5 w-3.5 shrink-0 text-info mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Local runtime skills (from your CLI's skills directory) are always available automatically — no need to add them here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{agent.skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills assigned</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add skills from the workspace to this agent.
|
||||
Add workspace skills to share team knowledge with this agent. Local skills are already used automatically.
|
||||
</p>
|
||||
{availableSkills.length > 0 && (
|
||||
<Button
|
||||
|
||||
@@ -726,12 +726,34 @@ describe("validateCliCallback", () => {
|
||||
expect(validateCliCallback("http://127.0.0.1:8080/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts 10.x.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://10.0.0.5:9876/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://10.255.255.255:1234/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts 172.16-31.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://172.16.0.1:9876/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://172.31.255.255:1234/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 172.x outside 16-31 range", () => {
|
||||
expect(validateCliCallback("http://172.15.0.1:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://172.32.0.1:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts 192.168.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://192.168.1.131:41117/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://192.168.0.1:8080/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects https:// URLs", () => {
|
||||
expect(validateCliCallback("https://localhost:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-localhost hosts", () => {
|
||||
it("rejects public IPs and domains", () => {
|
||||
expect(validateCliCallback("http://evil.com:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://8.8.8.8:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://192.169.1.1:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid URLs", () => {
|
||||
|
||||
@@ -68,14 +68,22 @@ function redirectToCliCallback(url: string, token: string, state: string) {
|
||||
window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`;
|
||||
}
|
||||
|
||||
/** Validate that a CLI callback URL points to localhost over HTTP. */
|
||||
/**
|
||||
* Validate that a CLI callback URL points to a safe host over HTTP.
|
||||
* Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups
|
||||
* on local VMs while blocking arbitrary public hosts.
|
||||
*/
|
||||
export function validateCliCallback(cliCallback: string): boolean {
|
||||
try {
|
||||
const cbUrl = new URL(cliCallback);
|
||||
if (cbUrl.protocol !== "http:") return false;
|
||||
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
|
||||
return false;
|
||||
return true;
|
||||
const h = cbUrl.hostname;
|
||||
if (h === "localhost" || h === "127.0.0.1") return true;
|
||||
// Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
|
||||
if (/^10\./.test(h)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
||||
if (/^192\.168\./.test(h)) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
552
packages/views/autopilots/components/autopilot-detail-page.tsx
Normal file
552
packages/views/autopilots/components/autopilot-detail-page.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
|
||||
import {
|
||||
useUpdateAutopilot,
|
||||
useDeleteAutopilot,
|
||||
useTriggerAutopilot,
|
||||
useCreateAutopilotTrigger,
|
||||
useDeleteAutopilotTrigger,
|
||||
} from "@multica/core/autopilots/mutations";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useNavigation, AppLink } from "../../navigation";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import {
|
||||
TriggerConfigSection,
|
||||
getDefaultTriggerConfig,
|
||||
toCronExpression,
|
||||
} from "./trigger-config";
|
||||
import type { TriggerConfig } from "./trigger-config";
|
||||
import type { AutopilotRun, AutopilotTrigger } from "@multica/core/types";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
issue_created: { label: "Issue Created", color: "text-blue-500", icon: Clock },
|
||||
running: { label: "Running", color: "text-blue-500", icon: Loader2 },
|
||||
completed: { label: "Completed", color: "text-emerald-500", icon: CheckCircle2 },
|
||||
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
function RunRow({ run }: { run: AutopilotRun }) {
|
||||
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors">
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
|
||||
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
|
||||
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{run.issue_id ? (
|
||||
<AppLink href={`/issues/${run.issue_id}`} className="hover:underline">
|
||||
Issue linked
|
||||
</AppLink>
|
||||
) : run.failure_reason ? (
|
||||
<span className="text-destructive">{run.failure_reason}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(run.triggered_at || run.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
|
||||
const deleteTrigger = useDeleteAutopilotTrigger();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium capitalize">{trigger.kind}</span>
|
||||
{trigger.label && (
|
||||
<span className="text-xs text-muted-foreground">({trigger.label})</span>
|
||||
)}
|
||||
{!trigger.enabled && (
|
||||
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
{trigger.cron_expression && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{trigger.cron_expression}
|
||||
{trigger.timezone && ` (${trigger.timezone})`}
|
||||
</div>
|
||||
)}
|
||||
{trigger.next_run_at && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Next: {formatDate(trigger.next_run_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => {
|
||||
deleteTrigger.mutate({ autopilotId, triggerId: trigger.id });
|
||||
toast.success("Trigger deleted");
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: "urgent", label: "Urgent" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const EXECUTION_MODE_OPTIONS = [
|
||||
{ value: "create_issue", label: "Create Issue" },
|
||||
{ value: "run_only", label: "Run Only" },
|
||||
];
|
||||
|
||||
function EditAutopilotDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
autopilot,
|
||||
agents,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
autopilot: { id: string; title: string; description?: string | null; assignee_id: string; priority: string; execution_mode: string; issue_title_template?: string | null };
|
||||
agents: { id: string; name: string; archived_at?: string | null }[];
|
||||
}) {
|
||||
const updateAutopilot = useUpdateAutopilot();
|
||||
const [title, setTitle] = useState(autopilot.title);
|
||||
const [description, setDescription] = useState(autopilot.description ?? "");
|
||||
const [assigneeId, setAssigneeId] = useState(autopilot.assignee_id);
|
||||
const [priority, setPriority] = useState(autopilot.priority);
|
||||
const [executionMode, setExecutionMode] = useState(autopilot.execution_mode);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const activeAgents = agents.filter((a) => !a.archived_at);
|
||||
|
||||
// Sync form when autopilot data changes (e.g. after optimistic update)
|
||||
useEffect(() => {
|
||||
setTitle(autopilot.title);
|
||||
setDescription(autopilot.description ?? "");
|
||||
setAssigneeId(autopilot.assignee_id);
|
||||
setPriority(autopilot.priority);
|
||||
setExecutionMode(autopilot.execution_mode);
|
||||
}, [autopilot]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !assigneeId || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await updateAutopilot.mutateAsync({
|
||||
id: autopilot.id,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
assignee_id: assigneeId,
|
||||
priority,
|
||||
execution_mode: executionMode as "create_issue" | "run_only",
|
||||
});
|
||||
onOpenChange(false);
|
||||
toast.success("Autopilot updated");
|
||||
} catch {
|
||||
toast.error("Failed to update autopilot");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogTitle>Edit Autopilot</DialogTitle>
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Daily code review"
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Prompt</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Step-by-step instructions for the agent..."
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent + Priority */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Agent</label>
|
||||
<Select value={assigneeId} onValueChange={(v) => v && setAssigneeId(v)}>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return "Select agent...";
|
||||
const agent = activeAgents.find((a) => a.id === value);
|
||||
return agent?.name ?? "Unknown Agent";
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeAgents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Priority</label>
|
||||
<Select value={priority} onValueChange={(v) => v && setPriority(v)}>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue>
|
||||
{(value: string | null) => PRIORITY_OPTIONS.find((o) => o.value === value)?.label ?? "Medium"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execution Mode */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Execution Mode</label>
|
||||
<Select value={executionMode} onValueChange={(v) => v && setExecutionMode(v)}>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue>
|
||||
{(value: string | null) => EXECUTION_MODE_OPTIONS.find((o) => o.value === value)?.label ?? "Create Issue"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXECUTION_MODE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || !assigneeId || submitting}>
|
||||
{submitting ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AddTriggerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
autopilotId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
autopilotId: string;
|
||||
}) {
|
||||
const createTrigger = useCreateAutopilotTrigger();
|
||||
const [config, setConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
|
||||
const [label, setLabel] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return;
|
||||
const cronExpr = toCronExpression(config);
|
||||
if (!cronExpr.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId,
|
||||
kind: "schedule",
|
||||
cron_expression: cronExpr,
|
||||
timezone: config.timezone || undefined,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setConfig(getDefaultTriggerConfig());
|
||||
setLabel("");
|
||||
toast.success("Trigger added");
|
||||
} catch {
|
||||
toast.error("Failed to add trigger");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle>Add Trigger</DialogTitle>
|
||||
<div className="space-y-4 pt-2">
|
||||
<TriggerConfigSection config={config} onChange={setConfig} />
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Label (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Weekday morning"
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Adding..." : "Add trigger"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
const wsId = useWorkspaceId();
|
||||
const router = useNavigation();
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const { data, isLoading } = useQuery(autopilotDetailOptions(wsId, autopilotId));
|
||||
const { data: runs = [], isLoading: runsLoading } = useQuery(autopilotRunsOptions(wsId, autopilotId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const updateAutopilot = useUpdateAutopilot();
|
||||
const deleteAutopilot = useDeleteAutopilot();
|
||||
const triggerAutopilot = useTriggerAutopilot();
|
||||
|
||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Autopilot not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { autopilot, triggers } = data;
|
||||
|
||||
const handleRunNow = async () => {
|
||||
try {
|
||||
await triggerAutopilot.mutateAsync(autopilotId);
|
||||
toast.success("Autopilot triggered");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "Failed to trigger autopilot");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteAutopilot.mutateAsync(autopilotId);
|
||||
toast.success("Autopilot deleted");
|
||||
router.push("/autopilots");
|
||||
} catch {
|
||||
toast.error("Failed to delete autopilot");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = () => {
|
||||
const newStatus = autopilot.status === "active" ? "paused" : "active";
|
||||
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLink href="/autopilots" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Zap className="h-4 w-4" />
|
||||
</AppLink>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
|
||||
<span className={cn(
|
||||
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
|
||||
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
|
||||
"bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{autopilot.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
|
||||
{autopilot.status === "active" ? (
|
||||
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
|
||||
) : (
|
||||
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
{triggerAutopilot.isPending ? "Running..." : "Run now"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
{/* Properties */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Properties</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Agent</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={20} />
|
||||
<span>{getActorName("agent", autopilot.assignee_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Priority</label>
|
||||
<div className="mt-1 capitalize">{autopilot.priority}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Execution Mode</label>
|
||||
<div className="mt-1">
|
||||
{autopilot.execution_mode === "create_issue" ? "Create Issue" : "Run Only"}
|
||||
</div>
|
||||
</div>
|
||||
{autopilot.description && (
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-muted-foreground">Prompt</label>
|
||||
<div className="mt-1 whitespace-pre-wrap text-sm">{autopilot.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Triggers */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Triggers</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setTriggerDialogOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add trigger
|
||||
</Button>
|
||||
</div>
|
||||
{triggers.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
No triggers configured. Add a schedule to run automatically.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{triggers.map((t) => (
|
||||
<TriggerRow key={t.id} trigger={t} autopilotId={autopilotId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Run History */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Run History</h2>
|
||||
{runsLoading ? (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
No runs yet. Click "Run now" to trigger manually.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<section className="space-y-3 pt-4 border-t">
|
||||
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">Danger Zone</h2>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Delete autopilot
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddTriggerDialog
|
||||
open={triggerDialogOpen}
|
||||
onOpenChange={setTriggerDialogOpen}
|
||||
autopilotId={autopilotId}
|
||||
/>
|
||||
<EditAutopilotDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
autopilot={autopilot}
|
||||
agents={agents}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
423
packages/views/autopilots/components/autopilots-page.tsx
Normal file
423
packages/views/autopilots/components/autopilots-page.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Zap, Play, Pause, AlertCircle, Newspaper, GitPullRequest, Bug, BarChart3, Shield, FileSearch } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotListOptions } from "@multica/core/autopilots/queries";
|
||||
import { useCreateAutopilot, useCreateAutopilotTrigger } from "@multica/core/autopilots/mutations";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import {
|
||||
TriggerConfigSection,
|
||||
getDefaultTriggerConfig,
|
||||
toCronExpression,
|
||||
} from "./trigger-config";
|
||||
import type { TriggerConfig } from "./trigger-config";
|
||||
import type { Autopilot } from "@multica/core/types";
|
||||
import type { TriggerFrequency } from "./trigger-config";
|
||||
|
||||
interface AutopilotTemplate {
|
||||
title: string;
|
||||
prompt: string;
|
||||
summary: string;
|
||||
icon: typeof Zap;
|
||||
frequency: TriggerFrequency;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const TEMPLATES: AutopilotTemplate[] = [
|
||||
{
|
||||
title: "Daily news digest",
|
||||
summary: "Search and summarize today's news for the team",
|
||||
prompt: `1. Search the web for news and announcements published today only (strictly today's date)
|
||||
2. Filter for topics relevant to our team and industry
|
||||
3. For each item, write a short summary including: title, source, key takeaways
|
||||
4. Compile everything into a single digest post
|
||||
5. Post the digest as a comment on this issue and @mention all workspace members`,
|
||||
icon: Newspaper,
|
||||
frequency: "daily",
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
title: "PR review reminder",
|
||||
summary: "Flag stale pull requests that need review",
|
||||
prompt: `1. List all open pull requests in the repository
|
||||
2. Identify PRs that have been open for more than 24 hours without a review
|
||||
3. For each stale PR, note the author, age, and a one-line summary of the change
|
||||
4. Post a comment on this issue listing all stale PRs with links
|
||||
5. @mention the team to remind them to review`,
|
||||
icon: GitPullRequest,
|
||||
frequency: "weekdays",
|
||||
time: "10:00",
|
||||
},
|
||||
{
|
||||
title: "Bug triage",
|
||||
summary: "Assess and prioritize new bug reports",
|
||||
prompt: `1. List all issues with status "triage" or "backlog" that have not been prioritized
|
||||
2. For each issue, read the description and any attached logs or screenshots
|
||||
3. Assess severity (critical / high / medium / low) based on user impact and scope
|
||||
4. Set the priority field on the issue accordingly
|
||||
5. Add a comment explaining your assessment and suggested next steps`,
|
||||
icon: Bug,
|
||||
frequency: "weekdays",
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
title: "Weekly progress report",
|
||||
summary: "Compile a weekly summary of team progress",
|
||||
prompt: `1. Gather all issues completed (status "done") in the past 7 days
|
||||
2. Gather all issues currently in progress
|
||||
3. Identify any blocked issues and their blockers
|
||||
4. Calculate key metrics: issues closed, issues opened, net change
|
||||
5. Write a structured weekly report with sections: Completed, In Progress, Blocked, Metrics
|
||||
6. Post the report as a comment on this issue`,
|
||||
icon: BarChart3,
|
||||
frequency: "weekly",
|
||||
time: "17:00",
|
||||
},
|
||||
{
|
||||
title: "Dependency audit",
|
||||
summary: "Scan for security vulnerabilities and outdated packages",
|
||||
prompt: `1. Run dependency audit tools on the project (npm audit, go vuln check, etc.)
|
||||
2. Identify any packages with known security vulnerabilities
|
||||
3. List outdated packages that are more than 2 major versions behind
|
||||
4. For each finding, note the severity, affected package, and recommended fix
|
||||
5. Post a summary report as a comment with actionable items`,
|
||||
icon: Shield,
|
||||
frequency: "weekly",
|
||||
time: "08:00",
|
||||
},
|
||||
{
|
||||
title: "Documentation check",
|
||||
summary: "Review recent changes for documentation gaps",
|
||||
prompt: `1. List all code changes merged in the past 7 days (via git log)
|
||||
2. For each significant change, check if related documentation was updated
|
||||
3. Identify any new APIs, config options, or features missing documentation
|
||||
4. Create a list of documentation gaps with file paths and suggested content
|
||||
5. Post the findings as a comment on this issue`,
|
||||
icon: FileSearch,
|
||||
frequency: "weekly",
|
||||
time: "14:00",
|
||||
},
|
||||
];
|
||||
|
||||
function formatRelativeDate(date: string): string {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days < 1) return "Today";
|
||||
if (days === 1) return "1d ago";
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Zap }> = {
|
||||
active: { label: "Active", color: "text-emerald-500", icon: Play },
|
||||
paused: { label: "Paused", color: "text-amber-500", icon: Pause },
|
||||
archived: { label: "Archived", color: "text-muted-foreground", icon: AlertCircle },
|
||||
};
|
||||
|
||||
const EXECUTION_MODE_LABELS: Record<string, string> = {
|
||||
create_issue: "Create Issue",
|
||||
run_only: "Run Only",
|
||||
};
|
||||
|
||||
function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
|
||||
const { getActorName } = useActorName();
|
||||
const statusCfg = (STATUS_CONFIG[autopilot.status] ?? STATUS_CONFIG["active"])!;
|
||||
const StatusIcon = statusCfg.icon;
|
||||
|
||||
return (
|
||||
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
|
||||
<AppLink
|
||||
href={`/autopilots/${autopilot.id}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{autopilot.title}</span>
|
||||
</AppLink>
|
||||
|
||||
{/* Agent */}
|
||||
<span className="flex w-32 items-center gap-1.5 shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} />
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Mode */}
|
||||
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
|
||||
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<span className={cn("flex w-20 items-center justify-center gap-1 shrink-0 text-xs", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
|
||||
{/* Last run */}
|
||||
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAutopilotDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template?: AutopilotTemplate | null;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const createAutopilot = useCreateAutopilot();
|
||||
const createTrigger = useCreateAutopilotTrigger();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Apply template when it changes
|
||||
const [appliedTemplate, setAppliedTemplate] = useState<AutopilotTemplate | null | undefined>(null);
|
||||
if (template !== appliedTemplate && open) {
|
||||
setAppliedTemplate(template);
|
||||
if (template) {
|
||||
setTitle(template.title);
|
||||
setDescription(template.prompt);
|
||||
setTriggerConfig({
|
||||
...getDefaultTriggerConfig(),
|
||||
frequency: template.frequency,
|
||||
time: template.time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const activeAgents = agents.filter((a) => !a.archived_at);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !assigneeId || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const autopilot = await createAutopilot.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
assignee_id: assigneeId,
|
||||
execution_mode: "create_issue",
|
||||
});
|
||||
|
||||
// Attach schedule trigger
|
||||
try {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId: autopilot.id,
|
||||
kind: "schedule",
|
||||
cron_expression: toCronExpression(triggerConfig),
|
||||
timezone: triggerConfig.timezone,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Autopilot created, but trigger failed to save");
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAssigneeId("");
|
||||
setTriggerConfig(getDefaultTriggerConfig());
|
||||
toast.success("Autopilot created");
|
||||
} catch {
|
||||
toast.error("Failed to create autopilot");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogTitle>New Autopilot</DialogTitle>
|
||||
<div className="space-y-5 pt-2">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Daily code review"
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Prompt</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Step-by-step instructions for the agent..."
|
||||
rows={6}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Agent</label>
|
||||
<Select value={assigneeId} onValueChange={(v) => v && setAssigneeId(v)}>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue>
|
||||
{(value: string | null) => {
|
||||
if (!value) return "Select agent...";
|
||||
const agent = activeAgents.find((a) => a.id === value);
|
||||
return agent?.name ?? "Unknown Agent";
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeAgents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Schedule</label>
|
||||
<div className="mt-2">
|
||||
<TriggerConfigSection config={triggerConfig} onChange={setTriggerConfig} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || !assigneeId || submitting}>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutopilotsPage() {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: autopilots = [], isLoading } = useQuery(autopilotListOptions(wsId));
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AutopilotTemplate | null>(null);
|
||||
|
||||
const openCreate = (template?: AutopilotTemplate) => {
|
||||
setSelectedTemplate(template ?? null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-medium">Autopilot</h1>
|
||||
{!isLoading && autopilots.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{autopilots.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => openCreate()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
New autopilot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-5 space-y-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : autopilots.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-16 px-5">
|
||||
<Zap className="h-10 w-10 mb-3 text-muted-foreground opacity-30" />
|
||||
<p className="text-sm text-muted-foreground">No autopilots yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-6">
|
||||
Schedule recurring tasks for your AI agents. Pick a template or start from scratch.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full max-w-3xl">
|
||||
{TEMPLATES.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.title}
|
||||
type="button"
|
||||
className="flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent/40"
|
||||
onClick={() => openCreate(t)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t.title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{t.summary}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="mt-4" onClick={() => openCreate()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Start from scratch
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Column headers */}
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
|
||||
<span className="shrink-0 w-4" />
|
||||
<span className="min-w-0 flex-1">Name</span>
|
||||
<span className="w-32 shrink-0">Agent</span>
|
||||
<span className="w-24 text-center shrink-0">Mode</span>
|
||||
<span className="w-20 text-center shrink-0">Status</span>
|
||||
<span className="w-20 text-right shrink-0">Last run</span>
|
||||
</div>
|
||||
{autopilots.map((autopilot) => (
|
||||
<AutopilotRow key={autopilot.id} autopilot={autopilot} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateAutopilotDialog open={createOpen} onOpenChange={setCreateOpen} template={selectedTemplate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
packages/views/autopilots/components/index.ts
Normal file
2
packages/views/autopilots/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AutopilotsPage } from "./autopilots-page";
|
||||
export { AutopilotDetailPage } from "./autopilot-detail-page";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user