mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
49 Commits
feat/chat-
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a4c3a1aa | ||
|
|
129a8b927f | ||
|
|
ce447c7f06 | ||
|
|
5dad1f0915 | ||
|
|
c0db3e0e76 | ||
|
|
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 | ||
|
|
2551aa53ef |
@@ -55,8 +55,10 @@ ALLOWED_ORIGINS=
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
# Only set explicitly if frontend and backend are on different domains.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_WS_URL=
|
||||
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
|
||||
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
|
||||
|
||||
@@ -189,7 +189,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktr
|
||||
|
||||
<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&theme=dark&legend=top-left" />
|
||||
<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>
|
||||
|
||||
@@ -177,7 +177,7 @@ make start
|
||||
|
||||
<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&theme=dark&legend=top-left" />
|
||||
<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>
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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,8 +29,10 @@ 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,10 @@ const nextConfig: NextConfig = {
|
||||
source: "/auth/:path*",
|
||||
destination: `${remoteApiUrl}/auth/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/uploads/:path*",
|
||||
destination: `${remoteApiUrl}/uploads/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,6 +55,8 @@ export const mockAgents: Agent[] = [
|
||||
runtime_mode: "cloud",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,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";
|
||||
@@ -523,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");
|
||||
@@ -551,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),
|
||||
@@ -577,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",
|
||||
@@ -782,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,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
@@ -44,6 +45,7 @@ import type {
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
ChatDonePayload,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
|
||||
const chatWsLogger = createLogger("chat.ws");
|
||||
@@ -116,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>>();
|
||||
@@ -286,13 +292,42 @@ 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
|
||||
@@ -409,6 +444,10 @@ export function useRealtimeSync(
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
unsubInvitationCreated();
|
||||
unsubInvitationAccepted();
|
||||
unsubInvitationDeclined();
|
||||
unsubInvitationRevoked();
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
@@ -436,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) {
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface Agent {
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
runtime_config: Record<string, unknown>;
|
||||
custom_env: Record<string, string>;
|
||||
custom_args: string[];
|
||||
custom_env_redacted: boolean;
|
||||
visibility: AgentVisibility;
|
||||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
@@ -67,6 +69,7 @@ export interface CreateAgentRequest {
|
||||
runtime_id: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
custom_args?: string[];
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
}
|
||||
@@ -79,6 +82,7 @@ export interface UpdateAgentRequest {
|
||||
runtime_id?: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
custom_args?: 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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -53,7 +53,11 @@ export type WSEventType =
|
||||
| "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;
|
||||
@@ -259,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";
|
||||
@@ -34,3 +34,20 @@ export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, Pe
|
||||
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
|
||||
@@ -337,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'
|
||||
|
||||
@@ -73,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
KeyRound,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type { Agent, RuntimeDevice } from "@multica/core/types";
|
||||
import type { Agent, RuntimeDevice, MemberWithUser } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -36,30 +37,36 @@ import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { TasksTab } from "./tabs/tasks-tab";
|
||||
import { SettingsTab } from "./tabs/settings-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
import { CustomArgsTab } from "./tabs/custom-args-tab";
|
||||
|
||||
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
|
||||
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
|
||||
}
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "custom_args" | "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: "custom_args", label: "Custom Args", icon: Terminal },
|
||||
{ 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>;
|
||||
@@ -163,6 +170,13 @@ export function AgentDetail({
|
||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||
{activeTab === "env" && (
|
||||
<EnvTab
|
||||
agent={agent}
|
||||
readOnly={agent.custom_env_redacted}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "custom_args" && (
|
||||
<CustomArgsTab
|
||||
agent={agent}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
@@ -171,6 +185,8 @@ export function AgentDetail({
|
||||
<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>
|
||||
|
||||
126
packages/views/agents/components/tabs/custom-args-tab.tsx
Normal file
126
packages/views/agents/components/tabs/custom-args-tab.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
} 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";
|
||||
|
||||
interface ArgEntry {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function argsToEntries(args: string[]): ArgEntry[] {
|
||||
return args.map((value) => ({ id: crypto.randomUUID(), value }));
|
||||
}
|
||||
|
||||
function entriesToArgs(entries: ArgEntry[]): string[] {
|
||||
return entries.flatMap((e) => e.value.trim().split(/\s+/)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function CustomArgsTab({
|
||||
agent,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ArgEntry[]>(
|
||||
argsToEntries(agent.custom_args ?? []),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentArgs = entriesToArgs(entries);
|
||||
const originalArgs = agent.custom_args ?? [];
|
||||
const dirty = JSON.stringify(currentArgs) !== JSON.stringify(originalArgs);
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries([...entries, { id: crypto.randomUUID(), value: "" }]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setEntries(entries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, value: string) => {
|
||||
setEntries(
|
||||
entries.map((entry, i) => (i === index ? { ...entry, value } : entry)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ custom_args: currentArgs });
|
||||
toast.success("Custom arguments saved");
|
||||
} catch {
|
||||
toast.error("Failed to save custom arguments");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Custom Arguments
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Additional CLI arguments appended to the agent command at launch
|
||||
(e.g. --model claude-sonnet-4-20250514)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEntry}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, e.target.value)}
|
||||
placeholder="--model claude-sonnet-4-20250514"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEntry(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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -46,9 +47,11 @@ function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
|
||||
|
||||
export function EnvTab({
|
||||
agent,
|
||||
readOnly = false,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
readOnly?: boolean;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
|
||||
@@ -111,6 +114,45 @@ export function EnvTab({
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
|
||||
@@ -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];
|
||||
@@ -191,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">
|
||||
@@ -214,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>
|
||||
|
||||
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";
|
||||
284
packages/views/autopilots/components/trigger-config.tsx
Normal file
284
packages/views/autopilots/components/trigger-config.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
|
||||
export type TriggerFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "custom";
|
||||
|
||||
export interface TriggerConfig {
|
||||
frequency: TriggerFrequency;
|
||||
time: string; // HH:MM
|
||||
dayOfWeek: number; // 0=Sun … 6=Sat
|
||||
cronExpression: string; // only used when frequency === "custom"
|
||||
timezone: string; // IANA
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FREQUENCIES: { value: TriggerFrequency; label: string }[] = [
|
||||
{ value: "hourly", label: "Hourly" },
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekdays", label: "Weekdays" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
const DAYS_OF_WEEK = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"America/Sao_Paulo",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Europe/Moscow",
|
||||
"Asia/Dubai",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Singapore",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Seoul",
|
||||
"Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getLocalTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezoneOffset(tz: string): string {
|
||||
if (tz === "UTC") return "UTC";
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "shortOffset",
|
||||
}).formatToParts(new Date());
|
||||
return parts.find((p) => p.type === "timeZoneName")?.value ?? tz;
|
||||
} catch {
|
||||
return tz;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezoneLabel(tz: string): string {
|
||||
if (tz === "UTC") return "UTC";
|
||||
const city = tz.split("/").pop()?.replace(/_/g, " ") ?? tz;
|
||||
return `${city} (${getTimezoneOffset(tz)})`;
|
||||
}
|
||||
|
||||
function formatTime12h(time: string): string {
|
||||
const [h, m] = time.split(":");
|
||||
const hour = parseInt(h ?? "9", 10);
|
||||
const min = parseInt(m ?? "0", 10);
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
return `${hour % 12 || 12}:${min.toString().padStart(2, "0")} ${ampm}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getDefaultTriggerConfig(): TriggerConfig {
|
||||
return {
|
||||
frequency: "daily",
|
||||
time: "09:00",
|
||||
dayOfWeek: 1,
|
||||
cronExpression: "0 9 * * 1-5",
|
||||
timezone: getLocalTimezone(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toCronExpression(cfg: TriggerConfig): string {
|
||||
const [h, m] = cfg.time.split(":");
|
||||
const hour = parseInt(h ?? "9", 10);
|
||||
const min = parseInt(m ?? "0", 10);
|
||||
switch (cfg.frequency) {
|
||||
case "hourly":
|
||||
return `${min} * * * *`;
|
||||
case "daily":
|
||||
return `${min} ${hour} * * *`;
|
||||
case "weekdays":
|
||||
return `${min} ${hour} * * 1-5`;
|
||||
case "weekly":
|
||||
return `${min} ${hour} * * ${cfg.dayOfWeek}`;
|
||||
case "custom":
|
||||
return cfg.cronExpression;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeTrigger(cfg: TriggerConfig): string {
|
||||
const offset = getTimezoneOffset(cfg.timezone);
|
||||
switch (cfg.frequency) {
|
||||
case "hourly": {
|
||||
const min = parseInt(cfg.time.split(":")[1] ?? "0", 10);
|
||||
return `Runs every hour at :${min.toString().padStart(2, "0")}`;
|
||||
}
|
||||
case "daily":
|
||||
return `Runs daily at ${formatTime12h(cfg.time)} ${offset}`;
|
||||
case "weekdays":
|
||||
return `Runs weekdays at ${formatTime12h(cfg.time)} ${offset}`;
|
||||
case "weekly":
|
||||
return `Runs every ${DAYS_OF_WEEK[cfg.dayOfWeek]} at ${formatTime12h(cfg.time)} ${offset}`;
|
||||
case "custom":
|
||||
return `Custom schedule: ${cfg.cronExpression}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TriggerConfigSection({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: TriggerConfig;
|
||||
onChange: (config: TriggerConfig) => void;
|
||||
}) {
|
||||
const timezones = useMemo(() => {
|
||||
const local = getLocalTimezone();
|
||||
const set = new Set(COMMON_TIMEZONES);
|
||||
return set.has(local) ? COMMON_TIMEZONES : [local, ...COMMON_TIMEZONES];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Frequency tabs */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FREQUENCIES.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
config.frequency === f.value
|
||||
? "bg-foreground text-background"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onChange({ ...config, frequency: f.value })}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{config.frequency === "custom" ? (
|
||||
/* Custom cron input */
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Cron Expression</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.cronExpression}
|
||||
onChange={(e) => onChange({ ...config, cronExpression: e.target.value })}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Standard 5-field cron (min hour dom month dow)
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Time + Timezone row */}
|
||||
<div className="flex gap-3">
|
||||
{config.frequency === "hourly" ? (
|
||||
<div className="w-24">
|
||||
<label className="text-xs text-muted-foreground">Minute</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={parseInt(config.time.split(":")[1] ?? "0", 10)}
|
||||
onChange={(e) => {
|
||||
const min = Math.max(0, Math.min(59, parseInt(e.target.value) || 0));
|
||||
onChange({ ...config, time: `00:${min.toString().padStart(2, "0")}` });
|
||||
}}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-28">
|
||||
<label className="text-xs text-muted-foreground">Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={config.time}
|
||||
onChange={(e) => onChange({ ...config, time: e.target.value || config.time })}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-xs text-muted-foreground">Timezone</label>
|
||||
<Select
|
||||
value={config.timezone}
|
||||
onValueChange={(v) => v && onChange({ ...config, timezone: v })}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue>
|
||||
{() => getTimezoneLabel(config.timezone)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timezones.map((tz) => (
|
||||
<SelectItem key={tz} value={tz}>
|
||||
{getTimezoneLabel(tz)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day-of-week selector for weekly */}
|
||||
{config.frequency === "weekly" && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Day</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{DAYS_OF_WEEK.map((day, i) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
config.dayOfWeek === i
|
||||
? "bg-foreground text-background"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onChange({ ...config, dayOfWeek: i })}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Human-readable preview */}
|
||||
<p className="text-xs text-muted-foreground">{describeTrigger(config)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,15 +14,8 @@ interface ChatInputProps {
|
||||
onStop?: () => void;
|
||||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Name of the currently selected agent, used in the default placeholder. */
|
||||
/** Name of the currently selected agent, used in the placeholder. */
|
||||
agentName?: string;
|
||||
/**
|
||||
* Full override for the placeholder text. When present, supersedes the
|
||||
* agentName-based default and the archived-session message. Caller uses
|
||||
* this to communicate agent-availability reasons (archived agent,
|
||||
* no_agents, etc.).
|
||||
*/
|
||||
placeholderOverride?: string;
|
||||
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
|
||||
leftAdornment?: ReactNode;
|
||||
}
|
||||
@@ -33,7 +26,6 @@ export function ChatInput({
|
||||
isRunning,
|
||||
disabled,
|
||||
agentName,
|
||||
placeholderOverride,
|
||||
leftAdornment,
|
||||
}: ChatInputProps) {
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
@@ -74,13 +66,11 @@ export function ChatInput({
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
const placeholder =
|
||||
placeholderOverride ??
|
||||
(disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…");
|
||||
const placeholder = disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…";
|
||||
|
||||
return (
|
||||
<div className="px-5 pb-3 pt-0">
|
||||
|
||||
@@ -34,62 +34,11 @@ import { ChatInput } from "./chat-input";
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
import { useChatResize } from "./use-chat-resize";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
|
||||
|
||||
const uiLogger = createLogger("chat.ui");
|
||||
const apiLogger = createLogger("chat.api");
|
||||
|
||||
/**
|
||||
* What we know about the agent the UI is currently tied to, plus whether
|
||||
* the user can actually send in this state. Derived each render from the
|
||||
* current session, selected agent, and available agents.
|
||||
*/
|
||||
type AgentUnavailableReason =
|
||||
| "no_agents" // workspace has no available agents at all
|
||||
| "archived" // agent exists but is archived (read-only session)
|
||||
| "missing"; // session refers to an agent that no longer exists
|
||||
|
||||
interface AgentState {
|
||||
/** Agent to display (possibly archived). Null when nothing to show. */
|
||||
agent: Agent | null;
|
||||
/** Whether the user can send a message in this state. */
|
||||
canSend: boolean;
|
||||
/** Why the user can't send. Absent when canSend is true. */
|
||||
reason?: AgentUnavailableReason;
|
||||
}
|
||||
|
||||
function sendBlockedMessage(reason: AgentUnavailableReason | undefined): string {
|
||||
switch (reason) {
|
||||
case "no_agents":
|
||||
return "No agents available — create one first";
|
||||
case "archived":
|
||||
return "This agent is archived and can't receive messages";
|
||||
case "missing":
|
||||
return "This session's agent no longer exists";
|
||||
default:
|
||||
return "Can't send right now";
|
||||
}
|
||||
}
|
||||
|
||||
function placeholderFor(
|
||||
reason: AgentUnavailableReason | undefined,
|
||||
agentName: string | undefined,
|
||||
isSessionArchived: boolean,
|
||||
): string {
|
||||
if (isSessionArchived) return "This session is archived";
|
||||
switch (reason) {
|
||||
case "no_agents":
|
||||
return "Create an agent to start chatting";
|
||||
case "archived":
|
||||
return "This agent is archived — conversation is read-only";
|
||||
case "missing":
|
||||
return "This session's agent is no longer available";
|
||||
default:
|
||||
return agentName ? `Tell ${agentName} what to do…` : "Tell me what to do…";
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatWindow() {
|
||||
const wsId = useWorkspaceId();
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
@@ -123,6 +72,12 @@ export function ChatWindow() {
|
||||
);
|
||||
const pendingTaskId = pendingTask?.task_id ?? null;
|
||||
|
||||
// Check if current session is archived
|
||||
const currentSession = activeSessionId
|
||||
? allSessions.find((s) => s.id === activeSessionId)
|
||||
: null;
|
||||
const isSessionArchived = currentSession?.status === "archived";
|
||||
|
||||
const qc = useQueryClient();
|
||||
const createSession = useCreateChatSession();
|
||||
const markRead = useMarkChatSessionRead();
|
||||
@@ -133,33 +88,11 @@ export function ChatWindow() {
|
||||
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
|
||||
);
|
||||
|
||||
// Current session (may be null for a fresh new chat). Used both to bound
|
||||
// the agent we show and to flag read-only sessions below.
|
||||
const currentSession = activeSessionId
|
||||
? allSessions.find((s) => s.id === activeSessionId)
|
||||
: null;
|
||||
const isSessionArchived = currentSession?.status === "archived";
|
||||
|
||||
// Resolve which agent the UI is tied to, plus whether the user can send.
|
||||
// Priority when a session is active: the session's bound agent from the
|
||||
// FULL list (may be archived — we still render it, read-only). Without a
|
||||
// session we pick the user's preference from the available set.
|
||||
const agentState = useMemo<AgentState>(() => {
|
||||
if (currentSession) {
|
||||
const bound = agents.find((a) => a.id === currentSession.agent_id) ?? null;
|
||||
if (!bound) return { agent: null, canSend: false, reason: "missing" };
|
||||
if (bound.archived_at) return { agent: bound, canSend: false, reason: "archived" };
|
||||
return { agent: bound, canSend: true };
|
||||
}
|
||||
const picked =
|
||||
availableAgents.find((a) => a.id === selectedAgentId) ??
|
||||
availableAgents[0] ??
|
||||
null;
|
||||
if (picked) return { agent: picked, canSend: true };
|
||||
return { agent: null, canSend: false, reason: "no_agents" };
|
||||
}, [currentSession, agents, availableAgents, selectedAgentId]);
|
||||
|
||||
const activeAgent = agentState.agent;
|
||||
// Resolve selected agent: stored preference → first available
|
||||
const activeAgent =
|
||||
availableAgents.find((a) => a.id === selectedAgentId) ??
|
||||
availableAgents[0] ??
|
||||
null;
|
||||
|
||||
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
|
||||
// fires on layout mount (login / workspace switch / fresh page load).
|
||||
@@ -223,11 +156,8 @@ export function ChatWindow() {
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string) => {
|
||||
if (!agentState.canSend || !activeAgent) {
|
||||
apiLogger.warn("sendChatMessage skipped", { reason: agentState.reason });
|
||||
// Surface why — handleSend is usually triggered by button or Enter,
|
||||
// silent failure is confusing.
|
||||
toast.error(sendBlockedMessage(agentState.reason));
|
||||
if (!activeAgent) {
|
||||
apiLogger.warn("sendChatMessage skipped: no active agent");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,59 +171,47 @@ export function ChatWindow() {
|
||||
contentLength: content.length,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!sessionId) {
|
||||
const session = await createSession.mutateAsync({
|
||||
agent_id: activeAgent.id,
|
||||
title: content.slice(0, 50),
|
||||
});
|
||||
sessionId = session.id;
|
||||
setActiveSession(sessionId);
|
||||
}
|
||||
|
||||
// Optimistic: show user message immediately.
|
||||
const optimistic: ChatMessage = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
chat_session_id: sessionId,
|
||||
role: "user",
|
||||
content,
|
||||
task_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<ChatMessage[]>(
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => (old ? [...old, optimistic] : [optimistic]),
|
||||
);
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
const result = await api.sendChatMessage(sessionId, content);
|
||||
apiLogger.info("sendChatMessage.success", {
|
||||
sessionId,
|
||||
messageId: result.message_id,
|
||||
taskId: result.task_id,
|
||||
if (!sessionId) {
|
||||
const session = await createSession.mutateAsync({
|
||||
agent_id: activeAgent.id,
|
||||
title: content.slice(0, 50),
|
||||
});
|
||||
// Seed pending-task optimistically so the spinner shows instantly —
|
||||
// the WS chat:message handler will invalidate + refetch to confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(sessionId), {
|
||||
task_id: result.task_id,
|
||||
status: "queued",
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
apiLogger.error("sendChatMessage.error", { err });
|
||||
// Drop the optimistic message — refetch the real list so the user's
|
||||
// bubble doesn't dangle without a reply.
|
||||
if (sessionId) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
}
|
||||
toast.error(`Failed to send: ${message}`);
|
||||
sessionId = session.id;
|
||||
setActiveSession(sessionId);
|
||||
}
|
||||
|
||||
// Optimistic: show user message immediately.
|
||||
const optimistic: ChatMessage = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
chat_session_id: sessionId,
|
||||
role: "user",
|
||||
content,
|
||||
task_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<ChatMessage[]>(
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => (old ? [...old, optimistic] : [optimistic]),
|
||||
);
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
const result = await api.sendChatMessage(sessionId, content);
|
||||
apiLogger.info("sendChatMessage.success", {
|
||||
sessionId,
|
||||
messageId: result.message_id,
|
||||
taskId: result.task_id,
|
||||
});
|
||||
// Seed pending-task optimistically so the spinner shows instantly —
|
||||
// the WS chat:message handler will invalidate + refetch to confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(sessionId), {
|
||||
task_id: result.task_id,
|
||||
status: "queued",
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
activeAgent,
|
||||
agentState,
|
||||
createSession,
|
||||
setActiveSession,
|
||||
qc,
|
||||
@@ -472,23 +390,17 @@ export function ChatWindow() {
|
||||
) : (
|
||||
<EmptyState
|
||||
agentName={activeAgent?.name}
|
||||
reason={agentState.reason}
|
||||
onPickPrompt={(text) => handleSend(text)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input — disabled for archived sessions or when no agent can accept */}
|
||||
{/* Input — disabled for archived sessions */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived || !agentState.canSend}
|
||||
disabled={isSessionArchived}
|
||||
agentName={activeAgent?.name}
|
||||
placeholderOverride={placeholderFor(
|
||||
agentState.reason,
|
||||
activeAgent?.name,
|
||||
!!isSessionArchived,
|
||||
)}
|
||||
leftAdornment={
|
||||
<AgentDropdown
|
||||
agents={availableAgents}
|
||||
@@ -685,25 +597,11 @@ const STARTER_PROMPTS: { icon: string; text: string }[] = [
|
||||
|
||||
function EmptyState({
|
||||
agentName,
|
||||
reason,
|
||||
onPickPrompt,
|
||||
}: {
|
||||
agentName?: string;
|
||||
reason?: AgentUnavailableReason;
|
||||
onPickPrompt: (text: string) => void;
|
||||
}) {
|
||||
// Can't chat → show the reason instead of the starter prompts.
|
||||
if (reason === "no_agents") {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 py-8 text-center">
|
||||
<h3 className="text-base font-semibold">No agents yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Create an agent from the Agents tab to start chatting.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
|
||||
<div className="text-center space-y-1">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type MarkdownProps as MarkdownBaseProps,
|
||||
type RenderMode,
|
||||
} from "@multica/ui/markdown";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { IssueMentionCard } from "../issues/components/issue-mention-card";
|
||||
|
||||
export type { RenderMode };
|
||||
@@ -30,27 +31,13 @@ function defaultRenderMention({
|
||||
}
|
||||
|
||||
/**
|
||||
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
|
||||
* Callers that need custom mention rendering can pass their own renderMention prop.
|
||||
* App-level Markdown wrapper that injects IssueMentionCard via renderMention
|
||||
* and cdnDomain from the config store for file card rendering.
|
||||
*/
|
||||
export function Markdown(props: MarkdownProps): React.JSX.Element {
|
||||
return <MarkdownBase renderMention={defaultRenderMention} {...props} />;
|
||||
const cdnDomain = useConfigStore((s) => s.cdnDomain);
|
||||
return <MarkdownBase renderMention={defaultRenderMention} cdnDomain={cdnDomain} {...props} />;
|
||||
}
|
||||
|
||||
export const MemoizedMarkdown = React.memo(
|
||||
Markdown,
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.id && nextProps.id) {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.mode === nextProps.mode
|
||||
);
|
||||
}
|
||||
return (
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.mode === nextProps.mode
|
||||
);
|
||||
},
|
||||
);
|
||||
export const MemoizedMarkdown = React.memo(Markdown);
|
||||
MemoizedMarkdown.displayName = "MemoizedMarkdown";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
@@ -112,12 +113,14 @@ function MarkButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
shortcut,
|
||||
isActive,
|
||||
}: {
|
||||
editor: Editor;
|
||||
mark: InlineMark;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -125,7 +128,7 @@ function MarkButton({
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive(mark)}
|
||||
pressed={isActive}
|
||||
onPressedChange={() => toggleMarkActions[mark](editor)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
@@ -239,9 +242,8 @@ function LinkEditBar({
|
||||
// Heading Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
|
||||
function HeadingDropdown({ editor, onOpenChange, activeLevel }: { editor: Editor; onOpenChange: (open: boolean) => void; activeLevel: number | undefined }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeLevel = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }));
|
||||
const label = activeLevel ? `H${activeLevel}` : "Text";
|
||||
const items = [
|
||||
{ label: "Normal Text", icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
|
||||
@@ -296,10 +298,8 @@ function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChang
|
||||
// List Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
|
||||
function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: Editor; onOpenChange: (open: boolean) => void; isBullet: boolean; isOrdered: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isBullet = editor.isActive("bulletList");
|
||||
const isOrdered = editor.isActive("orderedList");
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
@@ -359,10 +359,42 @@ function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange:
|
||||
function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
||||
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
|
||||
const menuElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Find the real scroll container once on mount
|
||||
// Precise subscription to formatting state — only re-renders when these
|
||||
// values actually change, replacing direct editor.isActive() calls that
|
||||
// relied on the parent re-rendering on every transaction.
|
||||
const fmt = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: e }) => ({
|
||||
bold: e.isActive("bold"),
|
||||
italic: e.isActive("italic"),
|
||||
strike: e.isActive("strike"),
|
||||
code: e.isActive("code"),
|
||||
link: e.isActive("link"),
|
||||
blockquote: e.isActive("blockquote"),
|
||||
bulletList: e.isActive("bulletList"),
|
||||
orderedList: e.isActive("orderedList"),
|
||||
heading1: e.isActive("heading", { level: 1 }),
|
||||
heading2: e.isActive("heading", { level: 2 }),
|
||||
heading3: e.isActive("heading", { level: 3 }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Find the real scroll container once the editor view is ready.
|
||||
// editor.view.dom throws if the view hasn't been mounted yet or has been
|
||||
// destroyed — the Proxy only stubs state/isDestroyed, everything else throws.
|
||||
// This race happens on fast page transitions in Desktop (Inbox switching)
|
||||
// because useEditor delays destruction via setTimeout(..., 1) for StrictMode
|
||||
// survival (TipTap issue #7346).
|
||||
useEffect(() => {
|
||||
setScrollTarget(getScrollParent(editor.view.dom));
|
||||
const detect = () => {
|
||||
if (!editor.isInitialized) return; // view not ready yet
|
||||
setScrollTarget(getScrollParent(editor.view.dom));
|
||||
};
|
||||
detect();
|
||||
editor.on("create", detect);
|
||||
return () => { editor.off("create", detect); };
|
||||
}, [editor]);
|
||||
|
||||
// Hide when the selection scrolls outside the scroll container's
|
||||
@@ -384,7 +416,14 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||||
// editor.view.coordsAtPos throws if the view has been destroyed
|
||||
// during a fast unmount race (same Proxy guard as view.dom above).
|
||||
let coords: { top: number };
|
||||
try {
|
||||
coords = editor.view.coordsAtPos(editor.state.selection.from);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
|
||||
if (scrollHiddenRef.current !== !visible) {
|
||||
@@ -418,6 +457,7 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
ref={menuElRef}
|
||||
editor={editor}
|
||||
shouldShow={shouldShowBubbleMenu}
|
||||
updateDelay={0}
|
||||
@@ -433,6 +473,17 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
shift: { padding: 8 },
|
||||
hide: true,
|
||||
scrollTarget,
|
||||
// Tiptap's React wrapper initialises the menu element with
|
||||
// position:absolute, but computePosition (called right after
|
||||
// show()) needs position:fixed so that getOffsetParent returns
|
||||
// the viewport instead of a positioned ancestor. Without this,
|
||||
// the first positioning computes coordinates relative to the
|
||||
// wrong containing block and the menu flies off-screen.
|
||||
onShow: () => {
|
||||
if (menuElRef.current) {
|
||||
menuElRef.current.style.position = "fixed";
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{mode === "link-edit" ? (
|
||||
@@ -440,25 +491,25 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
) : (
|
||||
<TooltipProvider delay={300}>
|
||||
<div className="bubble-menu">
|
||||
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} />
|
||||
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} />
|
||||
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} />
|
||||
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} />
|
||||
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} isActive={fmt.bold} />
|
||||
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} isActive={fmt.italic} />
|
||||
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} isActive={fmt.strike} />
|
||||
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} isActive={fmt.code} />
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Toggle size="sm" pressed={editor.isActive("link")} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
|
||||
<Toggle size="sm" pressed={fmt.link} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<Link2 className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>Link</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} activeLevel={fmt.heading1 ? 1 : fmt.heading2 ? 2 : fmt.heading3 ? 3 : undefined} />
|
||||
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} isBullet={fmt.bulletList} isOrdered={fmt.orderedList} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Toggle size="sm" pressed={editor.isActive("blockquote")} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
|
||||
<Toggle size="sm" pressed={fmt.blockquote} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
|
||||
}>
|
||||
<Quote className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createEditorExtensions } from "./extensions";
|
||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { EditorBubbleMenu } from "./bubble-menu";
|
||||
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
||||
import "./content-editor.css";
|
||||
@@ -122,6 +123,9 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
// Note: in v3.22.1 the default is already false/undefined (same behavior).
|
||||
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
|
||||
shouldRerenderOnTransaction: false,
|
||||
editable,
|
||||
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
@@ -152,18 +156,10 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
|
||||
const link = target.closest("a");
|
||||
const href = link?.getAttribute("href");
|
||||
if (!href || href.startsWith("mention://")) return false;
|
||||
if (!href || isMentionHref(href)) return false;
|
||||
|
||||
// Open the link. Internal paths use multica:navigate
|
||||
// (Electron hash-router safe), external open in new tab.
|
||||
event.preventDefault();
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
openLink(href);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
* FileCard — Tiptap node extension for rendering uploaded non-image files
|
||||
* as styled cards instead of plain markdown links.
|
||||
*
|
||||
* Markdown serialization: `[filename](href)` — standard link syntax.
|
||||
* Preprocessing in preprocess.ts converts standalone CDN file links back
|
||||
* to fileCard HTML on load, completing the roundtrip.
|
||||
* Markdown serialization: `!file[filename](href)` — custom syntax that is
|
||||
* unambiguous (standard `[name](url)` is indistinguishable from regular links).
|
||||
*
|
||||
* Loading pipeline: preprocessFileCards in preprocess.ts converts both the
|
||||
* new `!file[name](url)` syntax AND legacy `[name](cdnUrl)` lines into HTML
|
||||
* divs BEFORE @tiptap/markdown parses the content. The markdownTokenizer
|
||||
* below acts as a fallback for any direct markdown parsing that bypasses
|
||||
* preprocessing.
|
||||
*/
|
||||
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
@@ -15,30 +20,6 @@ import type { NodeViewProps } from "@tiptap/react";
|
||||
import { FileText, Loader2, Download } from "lucide-react";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDN URL detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i;
|
||||
|
||||
/** Check if a URL points to our upload CDN (CloudFront or S3 bucket). */
|
||||
export function isCdnUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (
|
||||
u.hostname.endsWith(".copilothub.ai") ||
|
||||
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. */
|
||||
export function isFileCardUrl(url: string): boolean {
|
||||
return isCdnUrl(url) && !IMAGE_EXTS.test(new URL(url).pathname);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -146,10 +127,31 @@ export const FileCardExtension = Node.create({
|
||||
];
|
||||
},
|
||||
|
||||
// Markdown serialization: fileCard → [filename](href)
|
||||
// Markdown: custom !file[name](url) syntax for unambiguous roundtrip.
|
||||
// Standard [name](url) is indistinguishable from regular links — the old
|
||||
// regex-based CDN hostname matching in preprocessFileCards was fragile.
|
||||
markdownTokenizer: {
|
||||
name: "fileCard",
|
||||
level: "block" as const,
|
||||
start(src: string) {
|
||||
return src.search(/^!file\[/m);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(/^!file\[([^\]]*)\]\((https?:\/\/[^)]+)\)/);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "fileCard",
|
||||
raw: match[0],
|
||||
attributes: { filename: match[1], href: match[2] },
|
||||
};
|
||||
},
|
||||
},
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("fileCard", token.attributes);
|
||||
},
|
||||
renderMarkdown: (node: any) => {
|
||||
const { href, filename } = node.attrs || {};
|
||||
return `[${filename || "file"}](${href})`;
|
||||
return `!file[${filename || "file"}](${href})`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
|
||||
@@ -15,20 +15,7 @@ import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { ExternalLink, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openLink(href: string) {
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
|
||||
function truncateUrl(url: string, max = 48): string {
|
||||
if (url.length <= max) return url;
|
||||
@@ -77,7 +64,10 @@ function useLinkHover(containerRef: React.RefObject<HTMLElement | null>, disable
|
||||
const link = target.closest("a") as HTMLAnchorElement | null;
|
||||
if (!link) return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("mention://")) return;
|
||||
if (!href || isMentionHref(href)) return;
|
||||
// Issue mention cards render as <a class="issue-mention"> — they
|
||||
// display their own rich info, a URL hover card is redundant.
|
||||
if (link.classList.contains("issue-mention")) return;
|
||||
|
||||
clearTimeout(hideTimer.current);
|
||||
showTimer.current = window.setTimeout(() => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useNavigation } from "../navigation";
|
||||
import { IssueMentionCard } from "../issues/components/issue-mention-card";
|
||||
import { ImageLightbox } from "./extensions/image-view";
|
||||
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
||||
import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -112,7 +113,7 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
|
||||
const components: Partial<Components> = {
|
||||
// Links — route mention:// to mention components, others show preview card
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("mention://")) {
|
||||
if (isMentionHref(href)) {
|
||||
const match = href.match(
|
||||
/^mention:\/\/(member|agent|issue|all)\/(.+)$/,
|
||||
);
|
||||
@@ -135,14 +136,7 @@ const components: Partial<Components> = {
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!href) return;
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
if (href) openLink(href);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
22
packages/views/editor/utils/link-handler.ts
Normal file
22
packages/views/editor/utils/link-handler.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared link handling utilities for the editor system.
|
||||
*
|
||||
* Used by content-editor (ProseMirror click handler), readonly-content
|
||||
* (react-markdown link component), and link-hover-card (Open button).
|
||||
*/
|
||||
|
||||
/** Open a link — internal paths dispatch multica:navigate, external open new tab. */
|
||||
export function openLink(href: string): void {
|
||||
if (href.startsWith("/")) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: href } }),
|
||||
);
|
||||
} else {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a href is a mention protocol link (should not be opened as a regular link). */
|
||||
export function isMentionHref(href: string | null | undefined): href is string {
|
||||
return !!href && href.startsWith("mention://");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { preprocessLinks } from "@multica/ui/markdown";
|
||||
import { preprocessMentionShortcodes } from "@multica/ui/markdown";
|
||||
import { isFileCardUrl } from "../extensions/file-card";
|
||||
import { preprocessLinks, preprocessMentionShortcodes, preprocessFileCards } from "@multica/ui/markdown";
|
||||
import { configStore } from "@multica/core/config";
|
||||
|
||||
/**
|
||||
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
|
||||
@@ -13,40 +12,14 @@ import { isFileCardUrl } from "../extensions/file-card";
|
||||
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
|
||||
* (old serialization format in database, migrated on read)
|
||||
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
|
||||
* 3. CDN file links on their own line → HTML div for fileCard node parsing
|
||||
* 3. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for
|
||||
* fileCard node parsing
|
||||
*/
|
||||
export function preprocessMarkdown(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
const cdnDomain = configStore.getState().cdnDomain;
|
||||
const step1 = preprocessMentionShortcodes(markdown);
|
||||
const step2 = preprocessLinks(step1);
|
||||
const step3 = preprocessFileCards(step2);
|
||||
const step3 = preprocessFileCards(step2, cdnDomain);
|
||||
return step3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert standalone `[name](cdnUrl)` lines into HTML that Tiptap's fileCard
|
||||
* parseHTML can recognise. Only matches non-image CDN URLs on their own line.
|
||||
*
|
||||
* Input: `[report.pdf](https://multica-static.copilothub.ai/xxx.pdf)`
|
||||
* Output: `<div data-type="fileCard" data-href="url" data-filename="report.pdf"></div>`
|
||||
*/
|
||||
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/;
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
}
|
||||
|
||||
function preprocessFileCards(markdown: string): string {
|
||||
return markdown
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
const match = trimmed.match(FILE_LINK_LINE);
|
||||
if (!match) return line;
|
||||
const filename = match[1]!;
|
||||
const url = match[2]!;
|
||||
if (!isFileCardUrl(url)) return line;
|
||||
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
2
packages/views/invite/index.ts
Normal file
2
packages/views/invite/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { InvitePage } from "./invite-page";
|
||||
export type { InvitePageProps } from "./invite-page";
|
||||
185
packages/views/invite/invite-page.tsx
Normal file
185
packages/views/invite/invite-page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Users, Check, X } from "lucide-react";
|
||||
|
||||
export interface InvitePageProps {
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
const { push } = useNavigation();
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
const qc = useQueryClient();
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [declining, setDeclining] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<"accepted" | "declined" | null>(null);
|
||||
|
||||
const { data: invitation, isLoading, error: fetchError } = useQuery({
|
||||
queryKey: ["invitation", invitationId],
|
||||
queryFn: () => api.getInvitation(invitationId),
|
||||
});
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.acceptInvitation(invitationId);
|
||||
setDone("accepted");
|
||||
// Refresh workspace list and switch to the new workspace.
|
||||
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
|
||||
const ws = wsList.find((w) => w.id === invitation?.workspace_id);
|
||||
if (ws) {
|
||||
switchWorkspace(ws);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
// Navigate to the workspace after a short delay for the success state.
|
||||
setTimeout(() => push("/issues"), 1000);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to accept invitation");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
setDeclining(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.declineInvitation(invitationId);
|
||||
setDone("declined");
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to decline invitation");
|
||||
} finally {
|
||||
setDeclining(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">Loading invitation...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !invitation) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<X className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Invitation not found</h2>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
This invitation may have expired, been revoked, or doesn't belong to your account.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => push("/issues")}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "accepted") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Check className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">You joined {invitation.workspace_name}!</h2>
|
||||
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "declined") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<h2 className="text-lg font-semibold">Invitation declined</h2>
|
||||
<p className="text-sm text-muted-foreground">You won't be added to this workspace.</p>
|
||||
<Button variant="outline" onClick={() => push("/issues")}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isExpired = invitation.status !== "pending";
|
||||
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-6 py-12">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
|
||||
<Users className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Join {invitation.workspace_name ?? "workspace"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>{invitation.inviter_name || invitation.inviter_email}</strong>{" "}
|
||||
invited you to join as {invitation.role === "admin" ? "an admin" : "a member"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAlreadyHandled ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This invitation has already been {invitation.status}.
|
||||
</div>
|
||||
) : isExpired ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This invitation has expired.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDecline}
|
||||
disabled={accepting || declining}
|
||||
>
|
||||
{declining ? "Declining..." : "Decline"}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleAccept}
|
||||
disabled={accepting || declining}
|
||||
>
|
||||
{accepting ? "Joining..." : "Accept & Join"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((e) => e.id === comment.id)) return old;
|
||||
return [...old, commentToTimelineEntry(comment)];
|
||||
return [...old, commentToTimelineEntry(comment)].sort(
|
||||
(a, b) => a.created_at.localeCompare(b.created_at),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -144,7 +146,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((e) => e.id === entry.id)) return old;
|
||||
return [...old, entry];
|
||||
return [...old, entry].sort(
|
||||
(a, b) => a.created_at.localeCompare(b.created_at),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
SquarePen,
|
||||
CircleUser,
|
||||
FolderKanban,
|
||||
Ellipsis,
|
||||
PinOff,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
|
||||
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
||||
@@ -56,10 +56,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
@@ -76,6 +81,7 @@ const personalNav = [
|
||||
const workspaceNav = [
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/projects", label: "Projects", icon: FolderKanban },
|
||||
{ href: "/autopilots", label: "Autopilot", icon: Zap },
|
||||
{ href: "/agents", label: "Agents", icon: Bot },
|
||||
];
|
||||
|
||||
@@ -164,6 +170,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
|
||||
|
||||
const wsId = workspace?.id;
|
||||
const { data: inboxItems = [] } = useQuery({
|
||||
@@ -197,6 +204,19 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const acceptInvitationMut = useMutation({
|
||||
mutationFn: (id: string) => api.acceptInvitation(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
});
|
||||
const declineInvitationMut = useMutation({
|
||||
mutationFn: (id: string) => api.declineInvitation(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
},
|
||||
});
|
||||
const logout = () => {
|
||||
queryClient.clear();
|
||||
authLogout();
|
||||
@@ -287,6 +307,44 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
Create workspace
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
{myInvitations.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Pending invitations
|
||||
</DropdownMenuLabel>
|
||||
{myInvitations.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<WorkspaceAvatar name={inv.workspace_name ?? "W"} size="sm" />
|
||||
<span className="flex-1 truncate text-sm">{inv.workspace_name ?? "Workspace"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={acceptInvitationMut.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
acceptInvitationMut.mutate(inv.id);
|
||||
}}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50"
|
||||
disabled={declineInvitationMut.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
declineInvitationMut.mutate(inv.id);
|
||||
}}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onClick={logout}>
|
||||
@@ -423,33 +481,51 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
|
||||
<SidebarFooter className="p-2">
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center gap-2.5 rounded-md px-2 py-1.5">
|
||||
<ActorAvatar
|
||||
name={user?.name ?? ""}
|
||||
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
|
||||
avatarUrl={user?.avatar_url}
|
||||
size={28}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium leading-tight">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground leading-tight">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
|
||||
<Ellipsis className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top" sideOffset={4}>
|
||||
<DropdownMenuItem variant="destructive" onClick={logout}>
|
||||
<Popover>
|
||||
<PopoverTrigger className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-accent transition-colors cursor-pointer">
|
||||
<ActorAvatar
|
||||
name={user?.name ?? ""}
|
||||
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
|
||||
avatarUrl={user?.avatar_url}
|
||||
size={28}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="truncate text-sm font-medium leading-tight">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground leading-tight">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" sideOffset={8} align="start" className="w-48 p-0">
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-2 border-b">
|
||||
<ActorAvatar
|
||||
name={user?.name ?? ""}
|
||||
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
|
||||
avatarUrl={user?.avatar_url}
|
||||
size={32}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useImmersiveMode } from "../platform";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
@@ -24,6 +25,11 @@ import {
|
||||
} from "../workspace/slug";
|
||||
|
||||
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
// This modal is full-screen, so it covers the app titlebar. On macOS desktop
|
||||
// we hide the traffic lights for its lifetime so the Back button in the top-
|
||||
// left corner isn't stolen by the native controls' hit-test. No-op elsewhere.
|
||||
useImmersiveMode();
|
||||
|
||||
const router = useNavigation();
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const [name, setName] = useState("");
|
||||
@@ -87,10 +93,20 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
showCloseButton={false}
|
||||
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
|
||||
>
|
||||
{/* Top drag region — restores window-drag ability that the full-screen
|
||||
modal would otherwise swallow. Transparent; web browsers ignore the
|
||||
-webkit-app-region property, so this is safe cross-platform. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 h-10"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-6 left-6 text-muted-foreground"
|
||||
className="absolute top-12 left-12 text-muted-foreground"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
onClick={onClose}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"./issues/utils/sort": "./issues/utils/sort.ts",
|
||||
"./issues/utils/redact": "./issues/utils/redact.ts",
|
||||
"./projects/components": "./projects/components/index.ts",
|
||||
"./autopilots/components": "./autopilots/components/index.ts",
|
||||
"./modals/registry": "./modals/registry.tsx",
|
||||
"./modals/create-issue": "./modals/create-issue.tsx",
|
||||
"./my-issues": "./my-issues/index.ts",
|
||||
@@ -32,7 +33,9 @@
|
||||
"./search": "./search/index.ts",
|
||||
"./chat": "./chat/index.ts",
|
||||
"./settings": "./settings/index.ts",
|
||||
"./onboarding": "./onboarding/index.ts"
|
||||
"./onboarding": "./onboarding/index.ts",
|
||||
"./invite": "./invite/index.ts",
|
||||
"./platform": "./platform/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
|
||||
1
packages/views/platform/index.ts
Normal file
1
packages/views/platform/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useImmersiveMode } from "./use-immersive-mode";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user