mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 23:19:17 +02:00
Compare commits
26 Commits
agent/lamb
...
fix/upload
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8411a30943 | ||
|
|
f0c0a64ddd | ||
|
|
2ecddc8fc8 | ||
|
|
2a2e6f4746 | ||
|
|
6538496ee4 | ||
|
|
69ef002bbb | ||
|
|
7dad45d444 | ||
|
|
7ade4b432d | ||
|
|
cbb2cf0c6c | ||
|
|
d94b704a71 | ||
|
|
76ba9cfb0b | ||
|
|
40aa23a528 | ||
|
|
d3f7570177 | ||
|
|
34e452776b | ||
|
|
2551aa53ef | ||
|
|
d779cbd183 | ||
|
|
10b6afc1ec | ||
|
|
4f58f0c8eb | ||
|
|
0399e387f8 | ||
|
|
a744cd4f45 | ||
|
|
bfa9bec8c4 | ||
|
|
bf71802451 | ||
|
|
09e6190400 | ||
|
|
0798b5f8bb | ||
|
|
e568896357 | ||
|
|
8748557c7b |
25
README.md
25
README.md
@@ -115,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
@@ -169,3 +184,13 @@ make dev
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -117,6 +117,21 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -157,3 +172,13 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
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/
|
||||
|
||||
@@ -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,14 +4,14 @@
|
||||
"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": "pnpm run bundle-cli && electron-builder",
|
||||
"lint": "eslint .",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
|
||||
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}`);
|
||||
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();
|
||||
}
|
||||
877
apps/desktop/src/main/daemon-manager.ts
Normal file
877
apps/desktop/src/main/daemon-manager.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
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";
|
||||
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
interface WatchedWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
runtime_count?: number;
|
||||
}
|
||||
|
||||
interface WatchListResponse {
|
||||
watched: WatchedWorkspace[];
|
||||
unwatched: string[];
|
||||
}
|
||||
|
||||
async function daemonFetch(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const active = await ensureActiveProfile();
|
||||
const url = `http://127.0.0.1:${active.port}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function listWatchedWorkspaces(): Promise<WatchListResponse> {
|
||||
const empty: WatchListResponse = { watched: [], unwatched: [] };
|
||||
try {
|
||||
const res = await daemonFetch("/watch");
|
||||
if (!res.ok) {
|
||||
// Older daemon versions don't have /watch. Treat as "nothing watched
|
||||
// yet" so the UI renders cleanly; the user can take manual action.
|
||||
if (res.status === 404) return empty;
|
||||
throw new Error(`list /watch failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as WatchListResponse;
|
||||
return {
|
||||
watched: Array.isArray(data.watched) ? data.watched : [],
|
||||
unwatched: Array.isArray(data.unwatched) ? data.unwatched : [],
|
||||
};
|
||||
} catch (err) {
|
||||
// Network errors (ECONNREFUSED when daemon is still starting, etc.) are
|
||||
// expected during startup races — return empty instead of throwing so
|
||||
// we don't spam the main-process error log on every poll.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log(`[daemon] list /watch unreachable: ${msg}`);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
async function watchWorkspace(id: string, name: string): Promise<void> {
|
||||
const res = await daemonFetch("/watch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspace_id: id, name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`watch failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function unwatchWorkspace(id: string): Promise<void> {
|
||||
const res = await daemonFetch(`/watch/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`unwatch failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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:list-watched", () => listWatchedWorkspaces());
|
||||
ipcMain.handle(
|
||||
"daemon:watch-workspace",
|
||||
(_event, id: string, name: string) => watchWorkspace(id, name),
|
||||
);
|
||||
ipcMain.handle("daemon:unwatch-workspace", (_event, id: string) =>
|
||||
unwatchWorkspace(id),
|
||||
);
|
||||
ipcMain.handle("daemon:is-cli-installed", async () => {
|
||||
const bin = await resolveCliBinary();
|
||||
return bin !== null;
|
||||
});
|
||||
ipcMain.handle("daemon:retry-install", async () => {
|
||||
cachedCliBinary = undefined;
|
||||
cliResolvePromise = null;
|
||||
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") 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) => {
|
||||
|
||||
45
apps/desktop/src/preload/index.d.ts
vendored
45
apps/desktop/src/preload/index.d.ts
vendored
@@ -5,6 +5,50 @@ 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>;
|
||||
listWatched: () => Promise<{
|
||||
watched: Array<{ id: string; name: string; runtime_count?: number }>;
|
||||
unwatched: string[];
|
||||
}>;
|
||||
watchWorkspace: (id: string, name: string) => Promise<void>;
|
||||
unwatchWorkspace: (id: string) => 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 +63,7 @@ declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
desktopAPI: DesktopAPI;
|
||||
daemonAPI: DaemonAPI;
|
||||
updater: UpdaterAPI;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,68 @@ 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"),
|
||||
listWatched: (): Promise<{
|
||||
watched: Array<{ id: string; name: string; runtime_count?: number }>;
|
||||
unwatched: string[];
|
||||
}> => ipcRenderer.invoke("daemon:list-watched"),
|
||||
watchWorkspace: (id: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:watch-workspace", id, name),
|
||||
unwatchWorkspace: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:unwatch-workspace", id),
|
||||
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 +100,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 +108,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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { useWorkspaceStore, workspaceListOptions } from "@multica/core/workspace";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
@@ -13,12 +14,33 @@ import { UpdateNotification } from "./components/update-notification";
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const [daemonRunning, setDaemonRunning] = useState(false);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
// Track daemon lifecycle so workspace reconciliation only runs when the
|
||||
// daemon is actually listening — avoids a startup race where the reconcile
|
||||
// effect fires before autoStart has spawned the child process.
|
||||
useEffect(() => {
|
||||
const unsub = window.daemonAPI.onStatusChange((s) => {
|
||||
setDaemonRunning(s.state === "running");
|
||||
});
|
||||
window.daemonAPI.getStatus().then((s) => {
|
||||
setDaemonRunning(s.state === "running");
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// 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 +50,64 @@ 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]);
|
||||
|
||||
// Reconcile the daemon's watched workspaces with what the user is a member
|
||||
// of. The query already hydrates on login and invalidates on create/delete
|
||||
// mutations, so this one effect covers both initial sync and incremental
|
||||
// updates. Opt-outs (unwatched denylist) are respected.
|
||||
const { data: workspaces } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const lastSyncedIds = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!user || !workspaces || !daemonRunning) return;
|
||||
(async () => {
|
||||
const state = await window.daemonAPI.listWatched().catch(() => null);
|
||||
if (!state) return;
|
||||
const watchedIds = new Set(state.watched.map((w) => w.id));
|
||||
const unwatchedIds = new Set(state.unwatched);
|
||||
const currentIds = new Set(workspaces.map((w) => w.id));
|
||||
|
||||
// Add: anything in the API list but not yet watched (and not opted out).
|
||||
for (const ws of workspaces) {
|
||||
if (watchedIds.has(ws.id) || unwatchedIds.has(ws.id)) continue;
|
||||
try {
|
||||
await window.daemonAPI.watchWorkspace(ws.id, ws.name);
|
||||
} catch (err) {
|
||||
console.warn("watch workspace failed", ws.id, err);
|
||||
}
|
||||
}
|
||||
// Remove: anything we previously synced that is no longer in the API
|
||||
// list (the user left or deleted it).
|
||||
for (const prevId of lastSyncedIds.current) {
|
||||
if (!currentIds.has(prevId)) {
|
||||
try {
|
||||
await window.daemonAPI.unwatchWorkspace(prevId);
|
||||
} catch (err) {
|
||||
console.warn("unwatch workspace failed", prevId, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSyncedIds.current = currentIds;
|
||||
})();
|
||||
}, [user, workspaces, daemonRunning]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
@@ -40,14 +120,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>
|
||||
|
||||
384
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
384
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { workspaceListOptions } from "@multica/core/workspace";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
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);
|
||||
|
||||
// Watched workspaces — populated from the daemon when the panel opens and
|
||||
// refreshed after every toggle so the checkbox state reflects reality.
|
||||
const { data: allWorkspaces } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: open,
|
||||
});
|
||||
const [watchedIds, setWatchedIds] = useState<Set<string>>(new Set());
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
const refreshWatched = useCallback(async () => {
|
||||
const state = await window.daemonAPI.listWatched().catch(() => null);
|
||||
if (state) setWatchedIds(new Set(state.watched.map((w) => w.id)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && status.state === "running") void refreshWatched();
|
||||
}, [open, status.state, refreshWatched]);
|
||||
|
||||
const handleToggleWatch = useCallback(
|
||||
async (id: string, name: string, nextChecked: boolean) => {
|
||||
setTogglingId(id);
|
||||
try {
|
||||
if (nextChecked) {
|
||||
await window.daemonAPI.watchWorkspace(id, name);
|
||||
} else {
|
||||
await window.daemonAPI.unwatchWorkspace(id);
|
||||
}
|
||||
await refreshWatched();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
nextChecked ? "Failed to watch workspace" : "Failed to unwatch workspace",
|
||||
{ description: err instanceof Error ? err.message : String(err) },
|
||||
);
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
},
|
||||
[refreshWatched],
|
||||
);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Watched workspaces */}
|
||||
{status.state === "running" && allWorkspaces && allWorkspaces.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Watched Workspaces</h3>
|
||||
<div className="rounded-lg border divide-y max-h-48 overflow-y-auto">
|
||||
{allWorkspaces.map((ws) => {
|
||||
const checked = watchedIds.has(ws.id);
|
||||
const disabled = togglingId === ws.id;
|
||||
return (
|
||||
<label
|
||||
key={ws.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2",
|
||||
disabled
|
||||
? "opacity-60 cursor-wait"
|
||||
: "cursor-pointer hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(next) =>
|
||||
handleToggleWatch(ws.id, ws.name, next === true)
|
||||
}
|
||||
/>
|
||||
<span className="truncate text-sm">{ws.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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 />
|
||||
|
||||
@@ -13,9 +13,14 @@ import { ProjectsPage } from "@multica/views/projects/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 { 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 +52,11 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingRoute() {
|
||||
const nav = useNavigation();
|
||||
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
|
||||
}
|
||||
|
||||
/** Route definitions shared by all tabs (no layout wrapper). */
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
@@ -76,15 +86,31 @@ 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: "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;
|
||||
}
|
||||
@@ -36,14 +36,19 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
## Reusable Skills
|
||||
|
||||
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
- Deployments
|
||||
- Migrations
|
||||
- Code reviews
|
||||
- Common patterns
|
||||
|
||||
Skills are shared across the workspace, so any agent (or human) can leverage them.
|
||||
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
|
||||
|
||||
## Multi-Workspace Support
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ const nextConfig: NextConfig = {
|
||||
source: "/auth/:path*",
|
||||
destination: `${remoteApiUrl}/auth/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/uploads/:path*",
|
||||
destination: `${remoteApiUrl}/uploads/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
Attachment,
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
PendingChatTasksResponse,
|
||||
SendChatMessageResponse,
|
||||
Project,
|
||||
CreateProjectRequest,
|
||||
@@ -703,6 +705,18 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
||||
}
|
||||
|
||||
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
||||
return this.fetch(`/api/chat/pending-tasks`);
|
||||
}
|
||||
|
||||
async markChatSessionRead(sessionId: string): Promise<void> {
|
||||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
async cancelTaskById(taskId: string): Promise<void> {
|
||||
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
@@ -2,15 +2,67 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { chatKeys } from "./queries";
|
||||
import { createLogger } from "../logger";
|
||||
import type { ChatSession } from "../types";
|
||||
|
||||
const logger = createLogger("chat.mut");
|
||||
|
||||
export function useCreateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { agent_id: string; title?: string }) =>
|
||||
api.createChatSession(data),
|
||||
mutationFn: (data: { agent_id: string; title?: string }) => {
|
||||
logger.info("createChatSession.start", { agent_id: data.agent_id, titleLength: data.title?.length ?? 0 });
|
||||
return api.createChatSession(data);
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
logger.info("createChatSession.success", { sessionId: session.id, agentId: session.agent_id });
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error("createChatSession.error", err);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the session's unread state server-side. Optimistically flips
|
||||
* has_unread to false in the cached lists so the FAB badge drops
|
||||
* immediately. The server broadcasts chat:session_read so other devices
|
||||
* also sync.
|
||||
*/
|
||||
export function useMarkChatSessionRead() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("markChatSessionRead.start", { sessionId });
|
||||
return api.markChatSessionRead(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
|
||||
|
||||
const clear = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
|
||||
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
@@ -23,7 +75,10 @@ export function useArchiveChatSession() {
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("archiveChatSession.start", { sessionId });
|
||||
return api.archiveChatSession(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
@@ -41,13 +96,16 @@ export function useArchiveChatSession() {
|
||||
),
|
||||
);
|
||||
|
||||
logger.debug("archiveChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("archiveChatSession.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _err, sessionId) => {
|
||||
logger.debug("archiveChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
|
||||
@@ -14,6 +14,11 @@ export const chatKeys = {
|
||||
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
|
||||
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
|
||||
/** Per-task execution messages — shared with issue agent cards. */
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
};
|
||||
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
@@ -49,3 +54,44 @@ export function chatMessagesOptions(sessionId: string) {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending task for a chat session — the "is something still running?" signal.
|
||||
* Refetched via WS invalidation in useRealtimeSync when chat:message / chat:done
|
||||
* / task:completed / task:failed arrive.
|
||||
*/
|
||||
export function pendingChatTaskOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTask(sessionId),
|
||||
queryFn: () => api.getPendingChatTask(sessionId),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline for a single task — rendered by both the live chat view (while a
|
||||
* task is running) and AssistantMessage (for completed tasks). WS
|
||||
* `task:message` events seed this cache in real time via useRealtimeSync.
|
||||
*/
|
||||
export function taskMessagesOptions(taskId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.taskMessages(taskId),
|
||||
queryFn: () => api.listTaskMessages(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate of in-flight chat tasks for the current user in this workspace.
|
||||
* Drives the FAB "running" indicator while the chat window is minimised —
|
||||
* no per-session query is active then, so we need this roll-up.
|
||||
*/
|
||||
export function pendingChatTasksOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTasks(wsId),
|
||||
queryFn: () => api.listPendingChatTasks(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const logger = createLogger("chat.store");
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFT_KEY = "multica:chat:draft";
|
||||
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
|
||||
// Prune empty entries so the blob doesn't grow unbounded.
|
||||
const pruned: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(drafts)) {
|
||||
if (v) pruned[k] = v;
|
||||
}
|
||||
if (Object.keys(pruned).length === 0) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
storage.setItem(key, JSON.stringify(pruned));
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 420;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
* Kept as a public type because existing consumers (chat-message-list,
|
||||
* views/chat types) import it. Items themselves no longer live in the
|
||||
* store — they flow through the React Query cache keyed by task id.
|
||||
*/
|
||||
export interface ChatTimelineItem {
|
||||
seq: number;
|
||||
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
||||
@@ -26,11 +61,10 @@ export interface ChatTimelineItem {
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
pendingTaskId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
inputDraft: string;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
@@ -38,13 +72,11 @@ export interface ChatState {
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setPendingTask: (taskId: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
addTimelineItem: (item: ChatTimelineItem) => void;
|
||||
clearTimeline: () => void;
|
||||
setInputDraft: (draft: string) => void;
|
||||
clearInputDraft: () => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
@@ -62,20 +94,26 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
return wsId ? `${base}:${wsId}` : base;
|
||||
};
|
||||
|
||||
const store = create<ChatState>((set) => ({
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
isOpen: false,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
pendingTaskId: null,
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
timelineItems: [],
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
setOpen: (open) => set({ isOpen: open }),
|
||||
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
||||
setOpen: (open) => {
|
||||
logger.debug("setOpen", { from: get().isOpen, to: open });
|
||||
set({ isOpen: open });
|
||||
},
|
||||
toggle: () => {
|
||||
const next = !get().isOpen;
|
||||
logger.debug("toggle", { to: next });
|
||||
set({ isOpen: next });
|
||||
},
|
||||
setActiveSession: (id) => {
|
||||
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
|
||||
if (id) {
|
||||
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
|
||||
} else {
|
||||
@@ -83,35 +121,36 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}
|
||||
set({ activeSessionId: id });
|
||||
},
|
||||
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
|
||||
setSelectedAgentId: (id) => {
|
||||
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
setInputDraft: (draft) => {
|
||||
if (draft) {
|
||||
storage.setItem(wsKey(DRAFT_KEY), draft);
|
||||
} else {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
const next = { ...get().inputDrafts, [sessionId]: draft };
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
clearInputDraft: (sessionId) => {
|
||||
const current = get().inputDrafts;
|
||||
if (!(sessionId in current)) {
|
||||
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
|
||||
return;
|
||||
}
|
||||
set({ inputDraft: draft });
|
||||
logger.info("clearInputDraft", { sessionId });
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
clearInputDraft: () => {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
set({ inputDraft: "" });
|
||||
},
|
||||
addTimelineItem: (item) =>
|
||||
set((s) => {
|
||||
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
|
||||
return {
|
||||
timelineItems: [...s.timelineItems, item].sort(
|
||||
(a, b) => a.seq - b.seq,
|
||||
),
|
||||
};
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
storage.setItem(CHAT_HEIGHT_KEY, String(h));
|
||||
// Dragging = user chose a manual size → exit expanded mode
|
||||
@@ -119,6 +158,7 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
set({ chatWidth: w, chatHeight: h, isExpanded: false });
|
||||
},
|
||||
setExpanded: (expanded) => {
|
||||
logger.info("setExpanded", { to: expanded });
|
||||
if (expanded) {
|
||||
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
|
||||
} else {
|
||||
@@ -129,11 +169,20 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
|
||||
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
|
||||
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
|
||||
logger.info("workspace rehydration", {
|
||||
prevSession: store.getState().activeSessionId,
|
||||
nextSession,
|
||||
prevAgent: store.getState().selectedAgentId,
|
||||
nextAgent,
|
||||
draftCount: Object.keys(nextDrafts).length,
|
||||
});
|
||||
store.setState({
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
timelineItems: [],
|
||||
activeSessionId: nextSession,
|
||||
selectedAgentId: nextAgent,
|
||||
inputDrafts: nextDrafts,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus } from "../../types";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
@@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20;
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
visitedAt: number;
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
|
||||
recordVisit: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (entry) =>
|
||||
recordVisit: (id) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== entry.id);
|
||||
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
|
||||
const filtered = state.items.filter((i) => i.id !== id);
|
||||
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ describe("clearWorkspaceStorage", () => {
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:drafts:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:expanded:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ const WORKSPACE_SCOPED_KEYS = [
|
||||
"multica_my_issues_view",
|
||||
"multica:chat:selectedAgentId",
|
||||
"multica:chat:activeSessionId",
|
||||
"multica:chat:drafts",
|
||||
"multica:chat:expanded",
|
||||
];
|
||||
|
||||
/** Remove all workspace-scoped storage entries for the given workspace. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -39,8 +40,14 @@ import type {
|
||||
IssueReactionRemovedPayload,
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
ChatDonePayload,
|
||||
} from "../types";
|
||||
|
||||
const chatWsLogger = createLogger("chat.ws");
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
|
||||
export interface RealtimeSyncStores {
|
||||
@@ -133,6 +140,9 @@ export function useRealtimeSync(
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat / task events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
"task:message", "task:completed", "task:failed",
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
@@ -283,6 +293,103 @@ export function useRealtimeSync(
|
||||
}
|
||||
});
|
||||
|
||||
// --- Chat / task events (global, survives ChatWindow unmount) ---
|
||||
//
|
||||
// Single source of truth: the Query cache. No Zustand writes here — the
|
||||
// earlier mirror caused a race where the cache and store disagreed
|
||||
// during the invalidate → refetch window and the UI rendered duplicates.
|
||||
//
|
||||
// task:message is written directly into the task-messages cache so the
|
||||
// live timeline updates in place. chat:message / chat:done /
|
||||
// task:completed / task:failed invalidate messages + pending-task so the
|
||||
// DB remains authoritative.
|
||||
|
||||
const unsubTaskMessage = ws.on("task:message", (p) => {
|
||||
const payload = p as TaskMessagePayload;
|
||||
qc.setQueryData<TaskMessagePayload[]>(
|
||||
["task-messages", payload.task_id],
|
||||
(old = []) => {
|
||||
if (old.some((m) => m.seq === payload.seq)) return old;
|
||||
return [...old, payload].sort((a, b) => a.seq - b.seq);
|
||||
},
|
||||
);
|
||||
chatWsLogger.debug("task:message (global)", {
|
||||
task_id: payload.task_id,
|
||||
seq: payload.seq,
|
||||
type: payload.type,
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers reused by chat lifecycle handlers.
|
||||
const invalidatePendingAggregate = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) qc.invalidateQueries({ queryKey: chatKeys.pendingTasks(id) });
|
||||
};
|
||||
const invalidateSessionLists = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
|
||||
}
|
||||
};
|
||||
|
||||
const unsubChatMessage = ws.on("chat:message", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:message (global)", { chat_session_id: payload.chat_session_id });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatDone = ws.on("chat:done", (p) => {
|
||||
const payload = p as ChatDonePayload;
|
||||
chatWsLogger.info("chat:done (global)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// Assistant message was just written and task flipped out of 'running'.
|
||||
// Clear pending-task cache immediately so the live-timeline-vs-assistant
|
||||
// race window collapses to zero — the subsequent refetch will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
// Assistant message just landed → has_unread may have flipped to true.
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
const unsubTaskCompleted = ws.on("task:completed", (p) => {
|
||||
const payload = p as TaskCompletedPayload;
|
||||
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
|
||||
chatWsLogger.info("task:completed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubTaskFailed = ws.on("task:failed", (p) => {
|
||||
const payload = p as TaskFailedPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
chatWsLogger.warn("task:failed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// No new message; just flip the pending signal.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatSessionRead = ws.on("chat:session_read", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:session_read (global)", payload);
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
@@ -302,6 +409,12 @@ export function useRealtimeSync(
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
@@ -5,10 +5,22 @@ export interface ChatSession {
|
||||
creator_id: string;
|
||||
title: string;
|
||||
status: "active" | "archived";
|
||||
/** True when the session has any unread assistant replies. List-only. */
|
||||
has_unread: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTaskItem {
|
||||
task_id: string;
|
||||
status: string;
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTasksResponse {
|
||||
tasks: PendingChatTaskItem[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
chat_session_id: string;
|
||||
@@ -22,3 +34,12 @@ export interface SendChatMessageResponse {
|
||||
message_id: string;
|
||||
task_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/chat/sessions/{id}/pending-task.
|
||||
* Both fields are absent when the session has no in-flight task.
|
||||
*/
|
||||
export interface ChatPendingTask {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export type WSEventType =
|
||||
| "issue_reaction:removed"
|
||||
| "chat:message"
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
@@ -170,6 +171,7 @@ export interface ActivityCreatedPayload {
|
||||
export interface TaskMessagePayload {
|
||||
task_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
seq: number;
|
||||
type: "text" | "thinking" | "tool_use" | "tool_result" | "error";
|
||||
tool?: string;
|
||||
@@ -182,6 +184,7 @@ export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -189,6 +192,7 @@ export interface TaskFailedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -196,6 +200,7 @@ export interface TaskCancelledPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -239,6 +244,10 @@ export interface ChatDonePayload {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionReadPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreatedPayload {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
|
||||
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
|
||||
@@ -115,7 +115,11 @@ function createComponents(
|
||||
const id = mentionMatch[2]
|
||||
|
||||
if (renderMention) {
|
||||
return <>{renderMention({ type, id })}</>
|
||||
// Let the custom renderer opt out for types it doesn't handle
|
||||
// by returning null/undefined — we then fall through to the
|
||||
// default styled span so nothing ever disappears silently.
|
||||
const rendered = renderMention({ type, id })
|
||||
if (rendered) return <>{rendered}</>
|
||||
}
|
||||
|
||||
// Fallback: render as a simple styled span
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Chat FAB: gentle color + border tint while a chat task is running.
|
||||
* Keeps the ring at the same thickness — only hue shifts towards brand
|
||||
* at half-cycle, no outer glow. */
|
||||
@keyframes chat-impulse {
|
||||
0%, 100% {
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||
}
|
||||
50% {
|
||||
color: var(--brand);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--brand) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-chat-impulse {
|
||||
animation: chat-impulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, FileText, Trash2 } from "lucide-react";
|
||||
import { Plus, FileText, Trash2, Info } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -65,7 +65,7 @@ export function SkillsTab({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Skills</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Reusable skills assigned to this agent. Manage skills on the Skills page.
|
||||
Workspace skills assigned to this agent.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -79,12 +79,19 @@ export function SkillsTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-info/20 bg-info/5 px-3 py-2.5">
|
||||
<Info className="h-3.5 w-3.5 shrink-0 text-info mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Local runtime skills (from your CLI's skills directory) are always available automatically — no need to add them here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{agent.skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills assigned</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add skills from the workspace to this agent.
|
||||
Add workspace skills to share team knowledge with this agent. Local skills are already used automatically.
|
||||
</p>
|
||||
{availableSkills.length > 0 && (
|
||||
<Button
|
||||
|
||||
@@ -1,28 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
export function ChatFab() {
|
||||
const wsId = useWorkspaceId();
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
const toggle = useChatStore((s) => s.toggle);
|
||||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
|
||||
|
||||
if (isOpen) return null;
|
||||
|
||||
const unreadSessionCount = sessions.filter((s) => s.has_unread).length;
|
||||
const isRunning = (pending?.tasks ?? []).length > 0;
|
||||
|
||||
const handleClick = () => {
|
||||
logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
|
||||
toggle();
|
||||
};
|
||||
|
||||
// Tooltip text communicates the state that isn't carried by the icon/badge.
|
||||
const tooltip = isRunning
|
||||
? "Multica is working..."
|
||||
: unreadSessionCount > 0
|
||||
? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}`
|
||||
: "Ask Multica";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={toggle}
|
||||
className="absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95",
|
||||
// Impulse the button itself while a chat task is running — no
|
||||
// outer ring to keep things calm.
|
||||
isRunning && "animate-chat-impulse",
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
{unreadSessionCount > 0 && (
|
||||
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex min-w-4 h-4 items-center justify-center rounded-full bg-brand px-1 text-xs font-semibold leading-none text-background">
|
||||
{unreadSessionCount > 9 ? "9+" : unreadSessionCount}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={10}>Ask Multica</TooltipContent>
|
||||
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
onStop?: () => void;
|
||||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Name of the currently selected agent, used in the placeholder. */
|
||||
agentName?: string;
|
||||
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
|
||||
leftAdornment?: ReactNode;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
isRunning,
|
||||
disabled,
|
||||
agentName,
|
||||
leftAdornment,
|
||||
}: ChatInputProps) {
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const inputDraft = useChatStore((s) => s.inputDraft);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||||
// Scope the new-chat draft by agent:
|
||||
// 1. Switching agents while composing a brand-new chat gives each
|
||||
// agent its own draft (no cross-agent leakage).
|
||||
// 2. Tiptap's Placeholder extension is only applied at mount; this
|
||||
// key changes on agent switch so the editor remounts and the
|
||||
// `Tell {agent} what to do…` placeholder refreshes.
|
||||
const draftKey =
|
||||
activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
|
||||
// Select a primitive — empty-string fallback keeps referential stability.
|
||||
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
|
||||
const setInputDraft = useChatStore((s) => s.setInputDraft);
|
||||
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
|
||||
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
|
||||
|
||||
const handleSend = () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || isRunning || disabled) return;
|
||||
if (!content || isRunning || disabled) {
|
||||
logger.debug("input.send skipped", {
|
||||
emptyContent: !content,
|
||||
isRunning,
|
||||
disabled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Capture draft key BEFORE onSend — creating a new session mutates
|
||||
// activeSessionId synchronously, so reading it after onSend would point
|
||||
// at the new session and leave the old draft orphaned.
|
||||
const keyAtSend = draftKey;
|
||||
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
|
||||
onSend(content);
|
||||
editorRef.current?.clearContent();
|
||||
clearInputDraft();
|
||||
clearInputDraft(keyAtSend);
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
const placeholder = disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…";
|
||||
|
||||
return (
|
||||
<div className="p-2 pt-0">
|
||||
<div className="relative flex min-h-16 max-h-40 flex-col rounded-lg bg-card pb-8 border-1 border-border transition-colors focus-within:border-brand">
|
||||
<div className="px-5 pb-3 pt-0">
|
||||
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
// Remount the editor when the active session changes so its
|
||||
// uncontrolled defaultValue picks up the new session's draft.
|
||||
key={draftKey}
|
||||
ref={editorRef}
|
||||
defaultValue={inputDraft}
|
||||
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => {
|
||||
setIsEmpty(!md.trim());
|
||||
setInputDraft(md);
|
||||
setInputDraft(draftKey, md);
|
||||
}}
|
||||
onSubmit={handleSend}
|
||||
debounceMs={100}
|
||||
// Chat is short-form — the floating formatting toolbar is
|
||||
// more distraction than feature here.
|
||||
showBubbleMenu={false}
|
||||
// Enter sends; Shift-Enter inserts a hard break.
|
||||
submitOnEnter
|
||||
/>
|
||||
</div>
|
||||
{leftAdornment && (
|
||||
<div className="absolute bottom-1.5 left-2 flex items-center">
|
||||
{leftAdornment}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { api } from "@multica/core/api";
|
||||
import { taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import type { ChatMessage, TaskMessagePayload } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
@@ -20,51 +20,113 @@ import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
timelineItems: ChatTimelineItem[];
|
||||
/** When set, streams the live timeline for this task from task-messages cache. */
|
||||
pendingTaskId: string | null;
|
||||
isWaiting: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
timelineItems,
|
||||
pendingTaskId,
|
||||
isWaiting,
|
||||
}: ChatMessageListProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
useAutoScroll(scrollRef);
|
||||
|
||||
const hasTimeline = timelineItems.length > 0;
|
||||
// Once the assistant message for this pending task has landed in the
|
||||
// messages list, AssistantMessage owns its rendering — suppress the live
|
||||
// timeline to avoid rendering the same content in two places during the
|
||||
// invalidate → refetch window.
|
||||
const pendingAlreadyPersisted = !!pendingTaskId && messages.some(
|
||||
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
|
||||
);
|
||||
|
||||
// Live timeline for the in-flight task. useRealtimeSync keeps this cache
|
||||
// current via setQueryData on task:message events.
|
||||
const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
|
||||
const { data: liveTaskMessages } = useQuery({
|
||||
...taskMessagesOptions(pendingTaskId ?? ""),
|
||||
enabled: showLiveTimeline,
|
||||
});
|
||||
const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
|
||||
const hasLive = showLiveTimeline && liveTimeline.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 overflow-y-auto px-4 py-3 space-y-4"
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{/* Live streaming timeline */}
|
||||
{hasTimeline && (
|
||||
<div className="w-full space-y-1.5">
|
||||
<TimelineView items={timelineItems} />
|
||||
</div>
|
||||
)}
|
||||
{isWaiting && !hasTimeline && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<div ref={scrollRef} style={fadeStyle} className="flex-1 overflow-y-auto">
|
||||
{/* Inner container matches issue / project detail width convention
|
||||
* (max-w-4xl + mx-auto) so switching between chat and content
|
||||
* views doesn't jolt the reading width. px-5 is a touch tighter
|
||||
* than issue-detail's px-8 because the chat window can be narrow. */}
|
||||
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{hasLive && (
|
||||
<div className="w-full space-y-1.5">
|
||||
<TimelineView items={liveTimeline} />
|
||||
</div>
|
||||
)}
|
||||
{isWaiting && !hasLive && !pendingAlreadyPersisted && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder shown while `chat_message` for a session is being fetched
|
||||
* (initial refresh, or switching to an un-cached session). Shape roughly
|
||||
* mirrors an assistant → user → assistant exchange so the window doesn't
|
||||
* shift under the user when real messages arrive.
|
||||
*/
|
||||
export function ChatMessageSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 w-3/4 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3.5 w-1/2 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="h-8 w-48 rounded-2xl bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 w-2/3 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3.5 w-5/6 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3.5 w-1/3 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toTimelineItem(m: TaskMessagePayload): ChatTimelineItem {
|
||||
return {
|
||||
seq: m.seq,
|
||||
type: m.type,
|
||||
tool: m.tool,
|
||||
content: m.content,
|
||||
input: m.input,
|
||||
output: m.output,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Message bubbles ─────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] break-words">
|
||||
{/* User messages are authored as markdown in ContentEditor, so
|
||||
* render them through the same pipeline as assistant replies.
|
||||
* Neutralise prose's leading/trailing margin so single-line
|
||||
* bubbles stay as compact as the plain-text version used to. */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -80,24 +142,15 @@ function AssistantMessage({
|
||||
}) {
|
||||
const taskId = message.task_id;
|
||||
|
||||
// Always fetch task messages for assistant messages with a task_id
|
||||
// Use the shared taskMessagesOptions so this cache entry is the same one
|
||||
// seeded by useRealtimeSync during task execution — zero refetch when the
|
||||
// task finishes, since WS already populated it.
|
||||
const { data: taskMessages } = useQuery({
|
||||
queryKey: ["task-messages", taskId],
|
||||
queryFn: () => api.listTaskMessages(taskId!),
|
||||
...taskMessagesOptions(taskId ?? ""),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(
|
||||
(m: TaskMessagePayload) => ({
|
||||
seq: m.seq,
|
||||
type: m.type,
|
||||
tool: m.tool,
|
||||
content: m.content,
|
||||
input: m.input,
|
||||
output: m.output,
|
||||
}),
|
||||
);
|
||||
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(toTimelineItem);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-1.5">
|
||||
|
||||
@@ -10,14 +10,15 @@ import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { allChatSessionsOptions } from "@multica/core/chat/queries";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import type { ChatSession, Agent } from "@multica/core/types";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
export function ChatSessionHistory() {
|
||||
const wsId = useWorkspaceId();
|
||||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const clearTimeline = useChatStore((s) => s.clearTimeline);
|
||||
const setPendingTask = useChatStore((s) => s.setPendingTask);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
|
||||
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||||
@@ -26,9 +27,15 @@ export function ChatSessionHistory() {
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
||||
|
||||
const handleSelectSession = (session: ChatSession) => {
|
||||
logger.info("selectSession", {
|
||||
from: activeSessionId,
|
||||
to: session.id,
|
||||
agentId: session.agent_id,
|
||||
status: session.status,
|
||||
});
|
||||
// Changing activeSessionId flips the query keys for messages +
|
||||
// pending-task; no manual clear needed.
|
||||
setActiveSession(session.id);
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
setShowHistory(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
@@ -24,43 +24,53 @@ import {
|
||||
chatSessionsOptions,
|
||||
allChatSessionsOptions,
|
||||
chatMessagesOptions,
|
||||
pendingChatTaskOptions,
|
||||
chatKeys,
|
||||
} from "@multica/core/chat/queries";
|
||||
import { useCreateChatSession } from "@multica/core/chat/mutations";
|
||||
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { ChatMessageList } from "./chat-message-list";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatSessionHistory } from "./chat-session-history";
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
import { useChatResize } from "./use-chat-resize";
|
||||
import { useWS } from "@multica/core/realtime";
|
||||
import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
|
||||
|
||||
const uiLogger = createLogger("chat.ui");
|
||||
const apiLogger = createLogger("chat.api");
|
||||
|
||||
export function ChatWindow() {
|
||||
const wsId = useWorkspaceId();
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const pendingTaskId = useChatStore((s) => s.pendingTaskId);
|
||||
const timelineItems = useChatStore((s) => s.timelineItems);
|
||||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||||
const setOpen = useChatStore((s) => s.setOpen);
|
||||
const showHistory = useChatStore((s) => s.showHistory);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const setPendingTask = useChatStore((s) => s.setPendingTask);
|
||||
const addTimelineItem = useChatStore((s) => s.addTimelineItem);
|
||||
const clearTimeline = useChatStore((s) => s.clearTimeline);
|
||||
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
||||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||||
const { data: rawMessages } = useQuery(
|
||||
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
|
||||
chatMessagesOptions(activeSessionId ?? ""),
|
||||
);
|
||||
// When no active session, always show empty — don't use stale cache
|
||||
const messages = activeSessionId ? rawMessages ?? [] : [];
|
||||
// Skeleton only shows for an un-cached session fetch. Cached switches
|
||||
// return data synchronously — no flash. `enabled: false` (new chat)
|
||||
// keeps isLoading false so the starter prompts aren't hidden.
|
||||
const showSkeleton = !!activeSessionId && messagesLoading;
|
||||
|
||||
// Server-authoritative pending task. Survives refresh / reopen / session
|
||||
// switch because it's keyed on sessionId in the Query cache; WS events
|
||||
// (chat:message / chat:done / task:*) keep it invalidated in real time.
|
||||
//
|
||||
// This is the SOLE source for pendingTaskId — no mirror in the store.
|
||||
const { data: pendingTask } = useQuery(
|
||||
pendingChatTaskOptions(activeSessionId ?? ""),
|
||||
);
|
||||
const pendingTaskId = pendingTask?.task_id ?? null;
|
||||
|
||||
// Check if current session is archived
|
||||
const currentSession = activeSessionId
|
||||
@@ -70,6 +80,7 @@ export function ChatWindow() {
|
||||
|
||||
const qc = useQueryClient();
|
||||
const createSession = useCreateChatSession();
|
||||
const markRead = useMarkChatSessionRead();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
@@ -83,87 +94,82 @@ export function ChatWindow() {
|
||||
availableAgents[0] ??
|
||||
null;
|
||||
|
||||
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
|
||||
// fires on layout mount (login / workspace switch / fresh page load).
|
||||
useEffect(() => {
|
||||
uiLogger.info("ChatWindow mount", {
|
||||
isOpen,
|
||||
activeSessionId,
|
||||
pendingTaskId,
|
||||
selectedAgentId,
|
||||
wsId,
|
||||
});
|
||||
return () => {
|
||||
uiLogger.info("ChatWindow unmount", {
|
||||
activeSessionId,
|
||||
pendingTaskId,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
|
||||
}, []);
|
||||
|
||||
// Auto-restore most recent active session from server (only once on mount)
|
||||
const didRestoreRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didRestoreRef.current) return;
|
||||
didRestoreRef.current = true;
|
||||
if (activeSessionId || sessions.length === 0) return;
|
||||
if (activeSessionId || sessions.length === 0) {
|
||||
uiLogger.debug("restore session skipped", {
|
||||
reason: activeSessionId ? "already has session" : "no sessions",
|
||||
activeSessionId,
|
||||
sessionCount: sessions.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const latest = sessions.find((s) => s.status === "active");
|
||||
if (latest) {
|
||||
uiLogger.info("restore session on mount", { sessionId: latest.id });
|
||||
setActiveSession(latest.id);
|
||||
} else {
|
||||
uiLogger.debug("restore session: no active session found");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
|
||||
}, [sessions]);
|
||||
|
||||
// Use ref for pendingTaskId so WS handlers always see the latest value
|
||||
// without needing to re-subscribe on every change.
|
||||
const pendingTaskRef = useRef<string | null>(pendingTaskId);
|
||||
pendingTaskRef.current = pendingTaskId;
|
||||
|
||||
const { subscribe } = useWS();
|
||||
// WS events are handled globally in useRealtimeSync — the query cache
|
||||
// stays current even when this window is closed. See packages/core/realtime/.
|
||||
|
||||
// Auto mark-as-read whenever the user is looking at a session with unread
|
||||
// state: window open + a session active + has_unread → PATCH.
|
||||
// has_unread comes from the list query; WS handlers invalidate it on
|
||||
// chat:done so a reply arriving while the user watches triggers this
|
||||
// effect again and is instantly cleared.
|
||||
const currentHasUnread =
|
||||
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
|
||||
useEffect(() => {
|
||||
// Returns true if the event was for our pending task and was handled.
|
||||
// Caller still decides whether to invalidate cache (chat:done / completed do; failed doesn't).
|
||||
const matchesPending = (taskId: string) =>
|
||||
!!pendingTaskRef.current && taskId === pendingTaskRef.current;
|
||||
|
||||
const finalizePending = (invalidateCache: boolean) => {
|
||||
if (invalidateCache) {
|
||||
const sid = useChatStore.getState().activeSessionId;
|
||||
if (sid) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sid) });
|
||||
}
|
||||
}
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
};
|
||||
|
||||
const unsubMessage = subscribe("task:message", (payload) => {
|
||||
const p = payload as TaskMessagePayload;
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
addTimelineItem({
|
||||
seq: p.seq,
|
||||
type: p.type,
|
||||
tool: p.tool,
|
||||
content: p.content,
|
||||
input: p.input,
|
||||
output: p.output,
|
||||
});
|
||||
});
|
||||
|
||||
const unsubDone = subscribe("chat:done", (payload) => {
|
||||
const p = payload as ChatDonePayload;
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
finalizePending(true);
|
||||
});
|
||||
|
||||
const unsubCompleted = subscribe("task:completed", (payload) => {
|
||||
const p = payload as { task_id: string };
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
finalizePending(true);
|
||||
});
|
||||
|
||||
const unsubFailed = subscribe("task:failed", (payload) => {
|
||||
const p = payload as { task_id: string };
|
||||
if (!matchesPending(p.task_id)) return;
|
||||
finalizePending(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMessage();
|
||||
unsubDone();
|
||||
unsubCompleted();
|
||||
unsubFailed();
|
||||
};
|
||||
}, [subscribe, addTimelineItem, clearTimeline, setPendingTask, qc]);
|
||||
if (!isOpen || !activeSessionId) return;
|
||||
if (!currentHasUnread) return;
|
||||
uiLogger.info("auto markRead", { sessionId: activeSessionId });
|
||||
markRead.mutate(activeSessionId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
|
||||
}, [isOpen, activeSessionId, currentHasUnread]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string) => {
|
||||
if (!activeAgent) return;
|
||||
if (!activeAgent) {
|
||||
apiLogger.warn("sendChatMessage skipped: no active agent");
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = activeSessionId;
|
||||
const isNewSession = !sessionId;
|
||||
|
||||
apiLogger.info("sendChatMessage.start", {
|
||||
sessionId,
|
||||
isNewSession,
|
||||
agentId: activeAgent.id,
|
||||
contentLength: content.length,
|
||||
});
|
||||
|
||||
if (!sessionId) {
|
||||
const session = await createSession.mutateAsync({
|
||||
@@ -187,9 +193,20 @@ export function ChatWindow() {
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => (old ? [...old, optimistic] : [optimistic]),
|
||||
);
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
const result = await api.sendChatMessage(sessionId, content);
|
||||
setPendingTask(result.task_id);
|
||||
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) });
|
||||
},
|
||||
[
|
||||
@@ -197,38 +214,88 @@ export function ChatWindow() {
|
||||
activeAgent,
|
||||
createSession,
|
||||
setActiveSession,
|
||||
setPendingTask,
|
||||
qc,
|
||||
],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!pendingTaskId) return;
|
||||
if (!pendingTaskId) {
|
||||
apiLogger.debug("cancelTask skipped: no pending task");
|
||||
return;
|
||||
}
|
||||
apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
|
||||
try {
|
||||
await api.cancelTaskById(pendingTaskId);
|
||||
} catch {
|
||||
apiLogger.info("cancelTask.success", { taskId: pendingTaskId });
|
||||
} catch (err) {
|
||||
// Task may already be completed
|
||||
apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err });
|
||||
}
|
||||
if (activeSessionId) {
|
||||
// Clear pending immediately; WS task:cancelled will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
|
||||
}
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
}, [pendingTaskId, activeSessionId, clearTimeline, setPendingTask, qc]);
|
||||
}, [pendingTaskId, activeSessionId, qc]);
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(agent: Agent) => {
|
||||
// No-op when clicking the already-active agent — don't clobber the
|
||||
// current session just because the user closed the menu this way.
|
||||
// Compare against activeAgent (what the UI shows), not selectedAgentId
|
||||
// (which may be null / point to an archived agent on first load).
|
||||
if (activeAgent && agent.id === activeAgent.id) return;
|
||||
uiLogger.info("selectAgent", {
|
||||
from: selectedAgentId,
|
||||
to: agent.id,
|
||||
previousSessionId: activeSessionId,
|
||||
});
|
||||
setSelectedAgentId(agent.id);
|
||||
// Reset session when switching agent
|
||||
setActiveSession(null);
|
||||
},
|
||||
[setSelectedAgentId, setActiveSession],
|
||||
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
uiLogger.info("newChat", {
|
||||
previousSessionId: activeSessionId,
|
||||
previousPendingTask: pendingTaskId,
|
||||
});
|
||||
setActiveSession(null);
|
||||
}, [activeSessionId, pendingTaskId, setActiveSession]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(session: ChatSession) => {
|
||||
// Sessions are bound 1:1 to an agent — picking a session from a
|
||||
// different agent implicitly switches the agent too.
|
||||
if (activeAgent && session.agent_id !== activeAgent.id) {
|
||||
uiLogger.info("selectSession (cross-agent)", {
|
||||
from: activeAgent.id,
|
||||
toAgent: session.agent_id,
|
||||
toSession: session.id,
|
||||
});
|
||||
setSelectedAgentId(session.agent_id);
|
||||
}
|
||||
setActiveSession(session.id);
|
||||
},
|
||||
[activeAgent, setSelectedAgentId, setActiveSession],
|
||||
);
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
uiLogger.info("minimize (close)", {
|
||||
activeSessionId,
|
||||
pendingTaskId,
|
||||
});
|
||||
setOpen(false);
|
||||
}, [activeSessionId, pendingTaskId, setOpen]);
|
||||
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
|
||||
|
||||
const hasMessages = messages.length > 0 || timelineItems.length > 0;
|
||||
// Show the list (vs empty state) as soon as there's anything to display —
|
||||
// a real message, or a pending task whose timeline will stream in.
|
||||
const hasMessages = messages.length > 0 || !!pendingTaskId;
|
||||
|
||||
const isVisible = isOpen && boundsReady;
|
||||
|
||||
@@ -248,115 +315,111 @@ export function ChatWindow() {
|
||||
return (
|
||||
<div ref={windowRef} className={containerClass} style={containerStyle}>
|
||||
<ChatResizeHandles onDragStart={startDrag} />
|
||||
{/* Header */}
|
||||
{!showHistory && (
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<AgentSelector
|
||||
{/* Header — ⊕ new + session dropdown | window tools */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={handleNewChat}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">New chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<SessionDropdown
|
||||
sessions={sessions}
|
||||
// Use the full agent list (incl. archived) so historical
|
||||
// sessions can still resolve their avatar.
|
||||
agents={agents}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isAtMax ? <Minimize2 /> : <Maximize2 />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{isAtMax ? "Restore" : "Expand"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleMinimize}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Minus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Minimize</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages / skeleton / empty state */}
|
||||
{showSkeleton ? (
|
||||
<ChatMessageSkeleton />
|
||||
) : hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
pendingTaskId={pendingTaskId}
|
||||
isWaiting={!!pendingTaskId}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
agentName={activeAgent?.name}
|
||||
onPickPrompt={(text) => handleSend(text)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input — disabled for archived sessions */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
agentName={activeAgent?.name}
|
||||
leftAdornment={
|
||||
<AgentDropdown
|
||||
agents={availableAgents}
|
||||
activeAgent={activeAgent}
|
||||
userId={user?.id}
|
||||
onSelect={handleSelectAgent}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setShowHistory(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<History />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Chat history</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setActiveSession(null);
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isAtMax ? <Minimize2 /> : <Maximize2 />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isAtMax ? "Restore" : "Expand"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Minus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Minimize</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHistory ? (
|
||||
<ChatSessionHistory />
|
||||
) : (
|
||||
<>
|
||||
{/* Messages or Empty State */}
|
||||
{hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
timelineItems={timelineItems}
|
||||
isWaiting={!!pendingTaskId}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState agentName={activeAgent?.name} />
|
||||
)}
|
||||
|
||||
{/* Input — disabled for archived sessions */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSelector({
|
||||
/**
|
||||
* Agent dropdown: avatar trigger, lists all available agents. Selecting a
|
||||
* different agent = switch agent + start a fresh chat (session=null).
|
||||
* The current agent is marked with a check and not clickable.
|
||||
*/
|
||||
function AgentDropdown({
|
||||
agents,
|
||||
activeAgent,
|
||||
userId,
|
||||
@@ -367,58 +430,54 @@ function AgentSelector({
|
||||
userId: string | undefined;
|
||||
onSelect: (agent: Agent) => void;
|
||||
}) {
|
||||
// Split into the user's own agents and everyone else so the menu groups
|
||||
// them — matches the old AgentSelector layout.
|
||||
const { mine, others } = useMemo(() => {
|
||||
const mine: Agent[] = [];
|
||||
const others: Agent[] = [];
|
||||
for (const a of agents) {
|
||||
if (a.owner_id === userId) mine.push(a);
|
||||
else others.push(a);
|
||||
}
|
||||
return { mine, others };
|
||||
}, [agents, userId]);
|
||||
|
||||
if (!activeAgent) {
|
||||
return <span className="text-sm text-muted-foreground">No agents</span>;
|
||||
return <span className="text-xs text-muted-foreground">No agents</span>;
|
||||
}
|
||||
|
||||
if (agents.length <= 1) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentAvatarSmall agent={activeAgent} />
|
||||
<span className="text-sm font-medium">{activeAgent.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const myAgents = agents.filter((a) => a.owner_id === userId);
|
||||
const othersAgents = agents.filter((a) => a.owner_id !== userId);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
<AgentAvatarSmall agent={activeAgent} />
|
||||
<span className="text-sm font-medium">{activeAgent.name}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-60 w-auto max-w-56">
|
||||
{myAgents.length > 0 && (
|
||||
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
|
||||
{mine.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>My Agents</DropdownMenuLabel>
|
||||
{myAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuLabel>My agents</DropdownMenuLabel>
|
||||
{mine.map((agent) => (
|
||||
<AgentMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
agent={agent}
|
||||
isCurrent={agent.id === activeAgent.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{myAgents.length > 0 && othersAgents.length > 0 && <DropdownMenuSeparator />}
|
||||
{othersAgents.length > 0 && (
|
||||
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
|
||||
{others.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Others</DropdownMenuLabel>
|
||||
{othersAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
{others.map((agent) => (
|
||||
<AgentMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
agent={agent}
|
||||
isCurrent={agent.id === activeAgent.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
@@ -427,28 +486,142 @@ function AgentSelector({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMenuItem({
|
||||
agent,
|
||||
isCurrent,
|
||||
onSelect,
|
||||
}: {
|
||||
agent: Agent;
|
||||
isCurrent: boolean;
|
||||
onSelect: (agent: Agent) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate flex-1">{agent.name}</span>
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session dropdown: lists ALL sessions across agents. Each row carries the
|
||||
* owning agent's avatar so the user can tell them apart. Selecting a
|
||||
* session from a different agent implicitly switches the agent too
|
||||
* (sessions are bound 1:1 to an agent). "New chat" lives in the header's
|
||||
* ⊕ button, not inside this dropdown.
|
||||
*/
|
||||
function SessionDropdown({
|
||||
sessions,
|
||||
agents,
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
}: {
|
||||
sessions: ChatSession[];
|
||||
agents: Agent[];
|
||||
activeSessionId: string | null;
|
||||
onSelectSession: (session: ChatSession) => void;
|
||||
}) {
|
||||
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||||
const title = activeSession?.title?.trim() || "New chat";
|
||||
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
{triggerAgent && <AgentAvatarSmall agent={triggerAgent} />}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
No previous chats
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
const agent = agentById.get(session.agent_id) ?? null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
{agent ? (
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
) : (
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-sm">
|
||||
{session.title?.trim() || "New chat"}
|
||||
</span>
|
||||
{session.has_unread && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-brand" />
|
||||
)}
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentAvatarSmall({ agent }: { agent: Agent }) {
|
||||
return (
|
||||
<Avatar className="size-5">
|
||||
<Avatar className="size-6">
|
||||
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">
|
||||
<Bot className="size-3" />
|
||||
<Bot className="size-3.5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ agentName }: { agentName?: string }) {
|
||||
/**
|
||||
* Three starter prompts shown on the empty state. Tapping one sends it
|
||||
* immediately — ChatGPT-style — because the point is showing users what
|
||||
* this chat is for: operating on the workspace, not open-ended Q&A.
|
||||
*/
|
||||
const STARTER_PROMPTS: { icon: string; text: string }[] = [
|
||||
{ icon: "📋", text: "List my open tasks by priority" },
|
||||
{ icon: "📝", text: "Summarize what I did today" },
|
||||
{ icon: "💡", text: "Plan what to work on next" },
|
||||
];
|
||||
|
||||
function EmptyState({
|
||||
agentName,
|
||||
onPickPrompt,
|
||||
}: {
|
||||
agentName?: string;
|
||||
onPickPrompt: (text: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8">
|
||||
<Send className="size-8 text-muted-foreground/50" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-base font-semibold">Welcome to Multica</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{agentName
|
||||
? `Chat with ${agentName} or ask anything`
|
||||
: "Ask anything or tell Multica what you need"}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
|
||||
<div className="text-center space-y-1">
|
||||
<h3 className="text-base font-semibold">
|
||||
{agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Try asking</p>
|
||||
</div>
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
{STARTER_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt.text}
|
||||
type="button"
|
||||
onClick={() => onPickPrompt(prompt.text)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
|
||||
>
|
||||
<span className="mr-2">{prompt.icon}</span>
|
||||
{prompt.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,10 @@ interface ContentEditorProps {
|
||||
onSubmit?: () => void;
|
||||
onBlur?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
/** Show the floating formatting toolbar on text selection. Defaults true. */
|
||||
showBubbleMenu?: boolean;
|
||||
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
interface ContentEditorRef {
|
||||
@@ -96,6 +100,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onUploadFile,
|
||||
showBubbleMenu = true,
|
||||
submitOnEnter = false,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -125,6 +131,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
queryClient,
|
||||
onSubmitRef,
|
||||
onUploadFileRef,
|
||||
submitOnEnter,
|
||||
}),
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
@@ -240,7 +247,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex-1 min-h-full" editor={editor} />
|
||||
{editable && <EditorBubbleMenu editor={editor} />}
|
||||
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface EditorExtensionsOptions {
|
||||
onUploadFileRef?: RefObject<
|
||||
((file: File) => Promise<UploadResult | null>) | undefined
|
||||
>;
|
||||
/** When true, bare Enter also submits (chat-style). Default false. */
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
export function createEditorExtensions(
|
||||
@@ -126,7 +128,15 @@ export function createEditorExtensions(
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => options.onSubmitRef?.current?.()),
|
||||
createSubmitExtension(
|
||||
() => {
|
||||
const fn = options.onSubmitRef?.current;
|
||||
if (!fn) return false; // no submit wired — let default Enter insert newline
|
||||
fn();
|
||||
return true;
|
||||
},
|
||||
{ submitOnEnter: options.submitOnEnter ?? false },
|
||||
),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export function createSubmitExtension(onSubmit: () => void) {
|
||||
/**
|
||||
* `onSubmit` must return true when it actually handled the event and false
|
||||
* when there's no submit handler wired up. That lets us fall through to the
|
||||
* default Enter behaviour — inserting a newline — when appropriate.
|
||||
*
|
||||
* `submitOnEnter` — when true, bare Enter also submits (chat-style). When
|
||||
* false, only Mod-Enter submits and bare Enter keeps its default (newline).
|
||||
*/
|
||||
export function createSubmitExtension(
|
||||
onSubmit: () => boolean,
|
||||
{ submitOnEnter }: { submitOnEnter: boolean },
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
const shortcuts: Record<string, () => boolean> = {
|
||||
"Mod-Enter": () => onSubmit(),
|
||||
};
|
||||
if (submitOnEnter) {
|
||||
shortcuts.Enter = () => {
|
||||
const editor = this.editor;
|
||||
// IME guard — never submit while composing a multi-key input
|
||||
// (Chinese pinyin, Japanese kana, etc). `view.composing` is set
|
||||
// by ProseMirror between compositionstart and compositionend.
|
||||
if (editor.view.composing) return false;
|
||||
// Let Enter insert a newline inside a code block.
|
||||
if (editor.isActive("codeBlock")) return false;
|
||||
return onSubmit();
|
||||
};
|
||||
}
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -367,12 +367,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
|
||||
useEffect(() => {
|
||||
if (issue) {
|
||||
recordVisit({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
});
|
||||
recordVisit(issue.id);
|
||||
}
|
||||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
|
||||
@@ -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("");
|
||||
@@ -83,13 +89,24 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
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" />
|
||||
|
||||
@@ -59,6 +59,8 @@ function getOnboardingIssues(): OnboardingIssueDef[] {
|
||||
description: [
|
||||
"Skills are reusable instructions that make agents better at recurring tasks — deployments, code reviews, migrations, etc.",
|
||||
"",
|
||||
"**Note:** Skills already installed in your local runtime (e.g., `.claude/skills/`) are automatically available to agents — no need to re-upload them. Workspace skills here are for sharing knowledge across your team.",
|
||||
"",
|
||||
"**Steps:**",
|
||||
"1. Go to **Skills** in the sidebar",
|
||||
"2. Click **New Skill**",
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"./search": "./search/index.ts",
|
||||
"./chat": "./chat/index.ts",
|
||||
"./settings": "./settings/index.ts",
|
||||
"./onboarding": "./onboarding/index.ts"
|
||||
"./onboarding": "./onboarding/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";
|
||||
28
packages/views/platform/use-immersive-mode.ts
Normal file
28
packages/views/platform/use-immersive-mode.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
type ImmersiveCapableAPI = {
|
||||
setImmersiveMode?: (immersive: boolean) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function getDesktopAPI(): ImmersiveCapableAPI | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as unknown as { desktopAPI?: ImmersiveCapableAPI }).desktopAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter "immersive" mode for the lifetime of the component that calls it.
|
||||
*
|
||||
* On macOS desktop this hides the traffic-light window controls so full-screen
|
||||
* modals (create-workspace, onboarding, etc.) can place UI in the top-left
|
||||
* corner without fighting the native controls' hit-test. On web or non-macOS
|
||||
* desktop this is a no-op.
|
||||
*/
|
||||
export function useImmersiveMode(): void {
|
||||
useEffect(() => {
|
||||
const api = getDesktopAPI();
|
||||
api?.setImmersiveMode?.(true);
|
||||
return () => {
|
||||
api?.setImmersiveMode?.(false);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -39,9 +39,22 @@ function getCliVersion(metadata: Record<string, unknown>): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLaunchedBy(metadata: Record<string, unknown>): string | null {
|
||||
if (
|
||||
metadata &&
|
||||
typeof metadata.launched_by === "string" &&
|
||||
metadata.launched_by
|
||||
) {
|
||||
return metadata.launched_by;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
|
||||
const cliVersion =
|
||||
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;
|
||||
const launchedBy =
|
||||
runtime.runtime_mode === "local" ? getLaunchedBy(runtime.metadata) : null;
|
||||
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const wsId = useWorkspaceId();
|
||||
@@ -146,6 +159,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
|
||||
runtimeId={runtime.id}
|
||||
currentVersion={cliVersion}
|
||||
isOnline={runtime.status === "online"}
|
||||
launchedBy={launchedBy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -20,7 +20,12 @@ import { RuntimeDetail } from "./runtime-detail";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
|
||||
export default function RuntimesPage() {
|
||||
interface RuntimesPageProps {
|
||||
/** Desktop-only slot rendered above the runtime list (e.g. local daemon card) */
|
||||
topSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RuntimesPage({ topSlot }: RuntimesPageProps = {}) {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
@@ -86,43 +91,46 @@ export default function RuntimesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="flex-1 min-h-0"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="list"
|
||||
defaultSize={280}
|
||||
minSize={240}
|
||||
maxSize={400}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{topSlot}
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="flex-1 min-h-0"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<RuntimeList
|
||||
runtimes={runtimes}
|
||||
selectedId={effectiveSelectedId}
|
||||
onSelect={setSelectedId}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
ownerFilter={ownerFilter}
|
||||
onOwnerFilterChange={setOwnerFilter}
|
||||
updatableIds={updatableIds}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizablePanel
|
||||
id="list"
|
||||
defaultSize={280}
|
||||
minSize={240}
|
||||
maxSize={400}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
>
|
||||
<RuntimeList
|
||||
runtimes={runtimes}
|
||||
selectedId={effectiveSelectedId}
|
||||
onSelect={setSelectedId}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
ownerFilter={ownerFilter}
|
||||
onOwnerFilterChange={setOwnerFilter}
|
||||
updatableIds={updatableIds}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel id="detail" minSize="50%">
|
||||
{selected ? (
|
||||
<RuntimeDetail key={selected.id} runtime={selected} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Server className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-sm">Select a runtime to view details</p>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<ResizablePanel id="detail" minSize="50%">
|
||||
{selected ? (
|
||||
<RuntimeDetail key={selected.id} runtime={selected} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Server className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-sm">Select a runtime to view details</p>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,13 +78,22 @@ interface UpdateSectionProps {
|
||||
runtimeId: string;
|
||||
currentVersion: string | null;
|
||||
isOnline: boolean;
|
||||
/**
|
||||
* Non-null when the daemon process was spawned by a managed launcher
|
||||
* (e.g. "desktop" for the Electron app). In that case the CLI binary
|
||||
* is shipped and upgraded by the launcher itself, so in-app self-update
|
||||
* is disabled — upgrading would be clobbered on the next launch anyway.
|
||||
*/
|
||||
launchedBy?: string | null;
|
||||
}
|
||||
|
||||
export function UpdateSection({
|
||||
runtimeId,
|
||||
currentVersion,
|
||||
isOnline,
|
||||
launchedBy,
|
||||
}: UpdateSectionProps) {
|
||||
const isManaged = launchedBy === "desktop";
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<RuntimeUpdateStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
@@ -165,33 +174,44 @@ export function UpdateSection({
|
||||
{currentVersion ?? "unknown"}
|
||||
</span>
|
||||
|
||||
{!hasUpdate && currentVersion && latestVersion && !status && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-success">
|
||||
<Check className="h-3 w-3" />
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasUpdate && !status && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<span className="text-xs font-mono text-info">
|
||||
{latestVersion}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">available</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasUpdate && isOnline && !status && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
{isManaged ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||
title="The CLI binary is managed by Multica Desktop — update Desktop to upgrade the CLI."
|
||||
>
|
||||
<ArrowUpCircle className="h-3 w-3" />
|
||||
Update
|
||||
</Button>
|
||||
Managed by Desktop
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{!hasUpdate && currentVersion && latestVersion && !status && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-success">
|
||||
<Check className="h-3 w-3" />
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasUpdate && !status && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<span className="text-xs font-mono text-info">
|
||||
{latestVersion}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">available</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasUpdate && isOnline && !status && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
>
|
||||
<ArrowUpCircle className="h-3 w-3" />
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{config && Icon && (
|
||||
|
||||
@@ -5,10 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchCommand } from "./search-command";
|
||||
import { useSearchStore } from "./search-store";
|
||||
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects } = vi.hoisted(() => ({
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchIssues: vi.fn(),
|
||||
mockSearchProjects: vi.fn(),
|
||||
mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> },
|
||||
mockAllIssues: { current: [] as Array<Record<string, unknown>> },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
@@ -19,12 +21,24 @@ vi.mock("@multica/core/api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores", () => ({
|
||||
useRecentIssuesStore: (selector?: (state: { items: [] }) => unknown) => {
|
||||
const state = { items: [] as [] };
|
||||
useRecentIssuesStore: (selector?: (state: { items: typeof mockRecentItems.current }) => unknown) => {
|
||||
const state = { items: mockRecentItems.current };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core", () => ({
|
||||
useWorkspaceId: () => "ws-test",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: mockAllIssues.current }),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: mockPush,
|
||||
@@ -36,6 +50,8 @@ describe("SearchCommand", () => {
|
||||
mockPush.mockReset();
|
||||
mockSearchIssues.mockReset().mockResolvedValue({ issues: [] });
|
||||
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
|
||||
mockRecentItems.current = [];
|
||||
mockAllIssues.current = [];
|
||||
|
||||
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -97,4 +113,39 @@ describe("SearchCommand", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/settings");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("renders recent issues from query cache joined with store visit records", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
{ id: "issue-2", visitedAt: 900 },
|
||||
];
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-1", title: "First issue", status: "todo" },
|
||||
{ id: "issue-2", identifier: "MUL-2", title: "Second issue", status: "done" },
|
||||
];
|
||||
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByText("First issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("MUL-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("MUL-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out recent items not present in query cache", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
{ id: "deleted-issue", visitedAt: 900 },
|
||||
];
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-1", title: "Existing issue", status: "in_progress" },
|
||||
];
|
||||
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByText("Existing issue")).toBeInTheDocument();
|
||||
expect(screen.queryByText("deleted-issue")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core";
|
||||
import { StatusIcon } from "../issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
@@ -97,7 +100,18 @@ export function SearchCommand() {
|
||||
const { push } = useNavigation();
|
||||
const open = useSearchStore((s) => s.open);
|
||||
const setOpen = useSearchStore((s) => s.setOpen);
|
||||
const recentIssues = useRecentIssuesStore((s) => s.items);
|
||||
const recentItems = useRecentIssuesStore((s) => s.items);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
const recentIssues = useMemo(() => {
|
||||
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
||||
return recentItems.flatMap((item) => {
|
||||
const issue = issueMap.get(item.id);
|
||||
return issue ? [issue] : [];
|
||||
});
|
||||
}, [recentItems, allIssues]);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { SettingsPage } from "./settings-page";
|
||||
export type { ExtraSettingsTab } from "./settings-page";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
@@ -22,7 +23,19 @@ const workspaceTabs = [
|
||||
{ value: "members", label: "Members", icon: Users },
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
export interface ExtraSettingsTab {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SettingsPageProps {
|
||||
/** Additional tabs injected by platform (e.g. desktop daemon settings) */
|
||||
extraAccountTabs?: ExtraSettingsTab[];
|
||||
}
|
||||
|
||||
export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
|
||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||
|
||||
return (
|
||||
@@ -41,6 +54,12 @@ export function SettingsPage() {
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{extraAccountTabs?.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
|
||||
{/* Workspace group */}
|
||||
<span className="px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground truncate">
|
||||
@@ -64,6 +83,9 @@ export function SettingsPage() {
|
||||
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
|
||||
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
|
||||
<TabsContent value="members"><MembersTab /></TabsContent>
|
||||
{extraAccountTabs?.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>{tab.content}</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { SettingsPage } from "./components";
|
||||
export type { ExtraSettingsTab } from "./components";
|
||||
|
||||
@@ -96,9 +96,9 @@ function CreateSkillDialog({
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Skill</DialogTitle>
|
||||
<DialogTitle>Add Workspace Skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new skill or import from ClawHub / Skills.sh.
|
||||
Create a new skill or import from ClawHub / Skills.sh. Workspace skills are shared with your team and automatically injected into agent runs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -744,9 +744,9 @@ export default function SkillsPage() {
|
||||
{skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
Skills define reusable instructions for agents.
|
||||
<p className="mt-3 text-sm text-muted-foreground">No workspace skills yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center max-w-[280px]">
|
||||
Workspace skills are shared across your team and injected into agent runs. Skills already installed in your local runtime are used automatically.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
@@ -788,6 +788,9 @@ export default function SkillsPage() {
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="mt-3 text-sm">Select a skill to view details</p>
|
||||
<p className="mt-1 text-xs text-center max-w-[260px]">
|
||||
Workspace skills supplement your local skills and are shared across the team.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
|
||||
@@ -99,13 +99,16 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
|
||||
appURL := resolveAppURL(cmd)
|
||||
|
||||
// Determine the callback host from the configured app URL.
|
||||
// For self-hosted setups where the browser is on a different machine,
|
||||
// we need to use the server's reachable hostname instead of localhost.
|
||||
// For self-hosted setups where the browser is on a different machine
|
||||
// (e.g. Multica running on a LAN server), use the server's private IP
|
||||
// so the browser can reach the CLI's local HTTP server.
|
||||
// For production (public hostnames like multica.ai), keep localhost —
|
||||
// the browser and CLI are on the same machine.
|
||||
callbackHost := "localhost"
|
||||
bindAddr := "127.0.0.1"
|
||||
if parsed, err := url.Parse(appURL); err == nil {
|
||||
h := parsed.Hostname()
|
||||
if h != "" && h != "localhost" && h != "127.0.0.1" {
|
||||
if ip := net.ParseIP(h); ip != nil && ip.IsPrivate() {
|
||||
callbackHost = h
|
||||
bindAddr = "0.0.0.0"
|
||||
}
|
||||
|
||||
@@ -272,6 +272,9 @@ func runDaemonForeground(cmd *cobra.Command) error {
|
||||
return err
|
||||
}
|
||||
cfg.CLIVersion = version
|
||||
// Set by the Electron Desktop app when it spawns the CLI so the server
|
||||
// can mark those runtimes as "managed" and hide CLI self-update UI.
|
||||
cfg.LaunchedBy = os.Getenv("MULTICA_LAUNCHED_BY")
|
||||
|
||||
ctx, stop := notifyShutdownContext(context.Background())
|
||||
defer stop()
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -22,6 +23,9 @@ var rootCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Version = fmt.Sprintf("%s (commit: %s, built: %s)\ngo: %s, os/arch: %s/%s", version, commit, date, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
rootCmd.SetVersionTemplate("multica {{.Version}}\n")
|
||||
|
||||
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
|
||||
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
|
||||
rootCmd.PersistentFlags().String("profile", "", "Configuration profile name (e.g. dev) — isolates config, daemon state, and workspaces")
|
||||
|
||||
@@ -241,6 +241,20 @@ func TestCommentTriggerOnComment(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply to member thread after agent replied triggers agent", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Member starts a thread (top-level comment).
|
||||
threadID := postComment(t, issueID, "Please fix this bug", nil)
|
||||
clearTasks(t, issueID)
|
||||
// Agent replies in the thread.
|
||||
postCommentAsAgent(t, issueID, "Working on it, found the root cause.", agentID, strPtr(threadID))
|
||||
// Member follows up in the same thread without @mentioning the agent.
|
||||
postComment(t, issueID, "Great, please also check the edge case", strPtr(threadID))
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task (agent participated in thread), got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply to member thread mentioning assignee triggers agent", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Member starts a thread.
|
||||
|
||||
@@ -321,8 +321,11 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
r.Delete("/", h.ArchiveChatSession)
|
||||
r.Post("/messages", h.SendChatMessage)
|
||||
r.Get("/messages", h.ListChatMessages)
|
||||
r.Get("/pending-task", h.GetPendingChatTask)
|
||||
r.Post("/read", h.MarkChatSessionRead)
|
||||
})
|
||||
})
|
||||
r.Get("/api/chat/pending-tasks", h.ListPendingChatTasks)
|
||||
|
||||
// Inbox
|
||||
r.Route("/api/inbox", func(r chi.Router) {
|
||||
|
||||
@@ -18,11 +18,45 @@ type WatchedWorkspace struct {
|
||||
|
||||
// CLIConfig holds persistent CLI settings.
|
||||
type CLIConfig struct {
|
||||
ServerURL string `json:"server_url,omitempty"`
|
||||
AppURL string `json:"app_url,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
WatchedWorkspaces []WatchedWorkspace `json:"watched_workspaces,omitempty"`
|
||||
ServerURL string `json:"server_url,omitempty"`
|
||||
AppURL string `json:"app_url,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
WatchedWorkspaces []WatchedWorkspace `json:"watched_workspaces,omitempty"`
|
||||
// UnwatchedWorkspaces is a denylist of workspace IDs the user has
|
||||
// explicitly opted out of. The daemon's periodic sync from the API
|
||||
// respects this list and won't re-add excluded workspaces.
|
||||
UnwatchedWorkspaces []string `json:"unwatched_workspaces,omitempty"`
|
||||
}
|
||||
|
||||
// IsUnwatched reports whether the given workspace ID is in the denylist.
|
||||
func (c *CLIConfig) IsUnwatched(id string) bool {
|
||||
for _, u := range c.UnwatchedWorkspaces {
|
||||
if u == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AddUnwatchedWorkspace adds an ID to the denylist. Returns true if added.
|
||||
func (c *CLIConfig) AddUnwatchedWorkspace(id string) bool {
|
||||
if c.IsUnwatched(id) {
|
||||
return false
|
||||
}
|
||||
c.UnwatchedWorkspaces = append(c.UnwatchedWorkspaces, id)
|
||||
return true
|
||||
}
|
||||
|
||||
// RemoveUnwatchedWorkspace removes an ID from the denylist. Returns true if removed.
|
||||
func (c *CLIConfig) RemoveUnwatchedWorkspace(id string) bool {
|
||||
for i, u := range c.UnwatchedWorkspaces {
|
||||
if u == id {
|
||||
c.UnwatchedWorkspaces = append(c.UnwatchedWorkspaces[:i], c.UnwatchedWorkspaces[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AddWatchedWorkspace adds a workspace to the watch list. Returns true if added.
|
||||
|
||||
@@ -32,6 +32,7 @@ type Config struct {
|
||||
DeviceName string
|
||||
RuntimeName string
|
||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
|
||||
Profile string // profile name (empty = default)
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
|
||||
@@ -86,6 +86,17 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no runtimes yet (empty watched list), run one sync cycle to discover
|
||||
// workspaces from the API before giving up. workspaceSyncLoop normally
|
||||
// handles this, but the runtime check below would fail before it runs.
|
||||
if len(d.allRuntimeIDs()) == 0 {
|
||||
d.syncWorkspacesFromAPI(ctx)
|
||||
// syncWorkspacesFromAPI writes to config; reload and register.
|
||||
if err := d.loadWatchedWorkspaces(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
runtimeIDs := d.allRuntimeIDs()
|
||||
if len(runtimeIDs) == 0 {
|
||||
return fmt.Errorf("no runtimes registered")
|
||||
@@ -156,8 +167,13 @@ func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
|
||||
return fmt.Errorf("load CLI config: %w", err)
|
||||
}
|
||||
|
||||
// It's fine to start with an empty watched list — workspaceSyncLoop runs
|
||||
// immediately on startup and will populate the list from the server. The
|
||||
// daemon also accepts HTTP POST /watch for explicit adds from clients
|
||||
// like the Desktop app.
|
||||
if len(cfg.WatchedWorkspaces) == 0 {
|
||||
return fmt.Errorf("no watched workspaces configured: run 'multica workspace watch <id>' to add one")
|
||||
d.logger.Info("no watched workspaces in config; workspaceSyncLoop will populate from API")
|
||||
return nil
|
||||
}
|
||||
|
||||
var registered int
|
||||
@@ -263,6 +279,7 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
|
||||
"daemon_id": d.cfg.DaemonID,
|
||||
"device_name": d.cfg.DeviceName,
|
||||
"cli_version": d.cfg.CLIVersion,
|
||||
"launched_by": d.cfg.LaunchedBy,
|
||||
"runtimes": runtimes,
|
||||
}
|
||||
|
||||
@@ -350,6 +367,9 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) {
|
||||
|
||||
var added int
|
||||
for _, ws := range workspaces {
|
||||
if cfg.IsUnwatched(ws.ID) {
|
||||
continue // user explicitly opted out
|
||||
}
|
||||
if cfg.AddWatchedWorkspace(ws.ID, ws.Name) {
|
||||
added++
|
||||
d.logger.Info("workspace sync: discovered new workspace", "workspace_id", ws.ID, "name", ws.Name)
|
||||
@@ -562,6 +582,19 @@ func (d *Daemon) handlePing(ctx context.Context, rt Runtime, pingID string) {
|
||||
|
||||
// handleUpdate performs the CLI update when triggered by the server via heartbeat.
|
||||
func (d *Daemon) handleUpdate(ctx context.Context, runtimeID string, update *PendingUpdate) {
|
||||
// Desktop-managed daemons share their CLI binary with the Electron app,
|
||||
// which is responsible for shipping and replacing it. Letting the daemon
|
||||
// self-update would just get overwritten on the next Desktop launch and
|
||||
// could brick the embedded binary mid-update. Refuse cleanly.
|
||||
if d.cfg.LaunchedBy == "desktop" {
|
||||
d.logger.Info("refusing CLI self-update: daemon is managed by Desktop", "runtime_id", runtimeID, "update_id", update.ID)
|
||||
d.client.ReportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
|
||||
"status": "failed",
|
||||
"error": "CLI is managed by Multica Desktop — update the Desktop app to upgrade the CLI",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent concurrent update attempts.
|
||||
if !d.updating.CompareAndSwap(false, true) {
|
||||
d.logger.Warn("update already in progress, ignoring", "runtime_id", runtimeID, "update_id", update.ID)
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/repocache"
|
||||
)
|
||||
|
||||
@@ -49,6 +51,19 @@ type repoCheckoutRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
// watchWorkspaceRequest is the body of a POST /watch request.
|
||||
type watchWorkspaceRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// watchedWorkspaceItem is one entry in the GET /watch response.
|
||||
type watchedWorkspaceItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Runtime int `json:"runtime_count"`
|
||||
}
|
||||
|
||||
// serveHealth runs the health HTTP server on the given listener.
|
||||
// Blocks until ctx is cancelled.
|
||||
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
|
||||
@@ -84,6 +99,31 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/watch", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
d.handleListWatched(w, r)
|
||||
case http.MethodPost:
|
||||
d.handleWatchWorkspace(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /watch/{workspace_id}
|
||||
mux.HandleFunc("/watch/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/watch/")
|
||||
if id == "" {
|
||||
http.Error(w, "workspace_id is required in path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d.handleUnwatchWorkspace(w, r, id)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -138,3 +178,135 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
|
||||
d.logger.Warn("health server error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListWatched returns the daemon's current watched workspaces merged
|
||||
// with the persisted config so clients can reflect "what is watched" and
|
||||
// "what has been explicitly opted out".
|
||||
func (d *Daemon) handleListWatched(w http.ResponseWriter, _ *http.Request) {
|
||||
cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile)
|
||||
if err != nil {
|
||||
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
items := make([]watchedWorkspaceItem, 0, len(cfg.WatchedWorkspaces))
|
||||
for _, ws := range cfg.WatchedWorkspaces {
|
||||
rc := 0
|
||||
if state, ok := d.workspaces[ws.ID]; ok {
|
||||
rc = len(state.runtimeIDs)
|
||||
}
|
||||
items = append(items, watchedWorkspaceItem{
|
||||
ID: ws.ID,
|
||||
Name: ws.Name,
|
||||
Runtime: rc,
|
||||
})
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"watched": items,
|
||||
"unwatched": cfg.UnwatchedWorkspaces,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWatchWorkspace registers a new workspace at runtime. Updates config
|
||||
// (removes from denylist, adds to watched list), registers runtimes via the
|
||||
// API, and records the result in daemon state. Idempotent.
|
||||
func (d *Daemon) handleWatchWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
var req watchWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
http.Error(w, "workspace_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile)
|
||||
if err != nil {
|
||||
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.RemoveUnwatchedWorkspace(req.WorkspaceID)
|
||||
cfg.AddWatchedWorkspace(req.WorkspaceID, req.Name)
|
||||
if err := cli.SaveCLIConfigForProfile(cfg, d.cfg.Profile); err != nil {
|
||||
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip registration if we're already tracking this workspace.
|
||||
d.mu.Lock()
|
||||
_, already := d.workspaces[req.WorkspaceID]
|
||||
d.mu.Unlock()
|
||||
if already {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "already_watching"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := d.registerRuntimesForWorkspace(r.Context(), req.WorkspaceID)
|
||||
if err != nil {
|
||||
d.logger.Error("watch: register failed", "workspace_id", req.WorkspaceID, "error", err)
|
||||
http.Error(w, "register runtimes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
runtimeIDs := make([]string, len(resp.Runtimes))
|
||||
for i, rt := range resp.Runtimes {
|
||||
runtimeIDs[i] = rt.ID
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.workspaces[req.WorkspaceID] = &workspaceState{workspaceID: req.WorkspaceID, runtimeIDs: runtimeIDs}
|
||||
for _, rt := range resp.Runtimes {
|
||||
d.runtimeIndex[rt.ID] = rt
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.repoCache != nil && len(resp.Repos) > 0 {
|
||||
go func(wsID string, repos []RepoData) {
|
||||
if err := d.repoCache.Sync(wsID, repoDataToInfo(repos)); err != nil {
|
||||
d.logger.Warn("repo cache sync failed", "workspace_id", wsID, "error", err)
|
||||
}
|
||||
}(req.WorkspaceID, resp.Repos)
|
||||
}
|
||||
|
||||
d.logger.Info("watch: now watching workspace", "workspace_id", req.WorkspaceID, "name", req.Name, "runtimes", len(resp.Runtimes))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "watching",
|
||||
"workspace_id": req.WorkspaceID,
|
||||
"runtime_count": len(runtimeIDs),
|
||||
})
|
||||
}
|
||||
|
||||
// handleUnwatchWorkspace stops tracking a workspace and records the opt-out
|
||||
// in the denylist so the periodic API sync won't revive it.
|
||||
func (d *Daemon) handleUnwatchWorkspace(w http.ResponseWriter, _ *http.Request, id string) {
|
||||
cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile)
|
||||
if err != nil {
|
||||
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.RemoveWatchedWorkspace(id)
|
||||
cfg.AddUnwatchedWorkspace(id)
|
||||
if err := cli.SaveCLIConfigForProfile(cfg, d.cfg.Profile); err != nil {
|
||||
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
if state, ok := d.workspaces[id]; ok {
|
||||
for _, rid := range state.runtimeIDs {
|
||||
delete(d.runtimeIndex, rid)
|
||||
}
|
||||
delete(d.workspaces, id)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
d.logger.Info("watch: stopped watching workspace", "workspace_id", id)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "unwatched", "workspace_id": id})
|
||||
}
|
||||
|
||||
@@ -73,27 +73,55 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
|
||||
var sessions []db.ChatSession
|
||||
var err error
|
||||
// Two call sites → two row types with identical shape. Collect into a
|
||||
// common response slice via small per-branch loops.
|
||||
var resp []ChatSessionResponse
|
||||
if status == "all" {
|
||||
sessions, err = h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
|
||||
rows, err := h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
resp = make([]ChatSessionResponse, len(rows))
|
||||
for i, s := range rows {
|
||||
resp[i] = ChatSessionResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
WorkspaceID: uuidToString(s.WorkspaceID),
|
||||
AgentID: uuidToString(s.AgentID),
|
||||
CreatorID: uuidToString(s.CreatorID),
|
||||
Title: s.Title,
|
||||
Status: s.Status,
|
||||
HasUnread: s.HasUnread,
|
||||
CreatedAt: timestampToString(s.CreatedAt),
|
||||
UpdatedAt: timestampToString(s.UpdatedAt),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sessions, err = h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
|
||||
rows, err := h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]ChatSessionResponse, len(sessions))
|
||||
for i, s := range sessions {
|
||||
resp[i] = chatSessionToResponse(s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
resp = make([]ChatSessionResponse, len(rows))
|
||||
for i, s := range rows {
|
||||
resp[i] = ChatSessionResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
WorkspaceID: uuidToString(s.WorkspaceID),
|
||||
AgentID: uuidToString(s.AgentID),
|
||||
CreatorID: uuidToString(s.CreatorID),
|
||||
Title: s.Title,
|
||||
Status: s.Status,
|
||||
HasUnread: s.HasUnread,
|
||||
CreatedAt: timestampToString(s.CreatedAt),
|
||||
UpdatedAt: timestampToString(s.UpdatedAt),
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -273,6 +301,127 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// PendingChatTaskResponse is returned by GetPendingChatTask — either the
|
||||
// current in-flight task's id/status, or an empty object when none is active.
|
||||
type PendingChatTaskResponse struct {
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// MarkChatSessionRead clears the session's unread_since (→ has_unread=false)
|
||||
// and broadcasts chat:session_read so other devices of the same user drop
|
||||
// their badges.
|
||||
func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
|
||||
ID: parseUUID(sessionID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "chat session not found")
|
||||
return
|
||||
}
|
||||
if uuidToString(session.CreatorID) != userID {
|
||||
writeError(w, http.StatusForbidden, "not your chat session")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.MarkChatSessionRead(r.Context(), parseUUID(sessionID)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark session read")
|
||||
return
|
||||
}
|
||||
|
||||
h.publish(protocol.EventChatSessionRead, workspaceID, "member", userID, protocol.ChatSessionReadPayload{
|
||||
ChatSessionID: sessionID,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PendingChatTasksResponse is the aggregate view consumed by the FAB.
|
||||
type PendingChatTasksResponse struct {
|
||||
Tasks []PendingChatTaskItem `json:"tasks"`
|
||||
}
|
||||
|
||||
type PendingChatTaskItem struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// ListPendingChatTasks returns every in-flight chat task owned by the current
|
||||
// user in this workspace. Drives the FAB's "running" indicator when the chat
|
||||
// window is closed (no per-session query is subscribed).
|
||||
func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
|
||||
rows, err := h.Queries.ListPendingChatTasksByCreator(r.Context(), db.ListPendingChatTasksByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list pending chat tasks")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]PendingChatTaskItem, len(rows))
|
||||
for i, row := range rows {
|
||||
items[i] = PendingChatTaskItem{
|
||||
TaskID: uuidToString(row.TaskID),
|
||||
Status: row.Status,
|
||||
ChatSessionID: uuidToString(row.ChatSessionID),
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, PendingChatTasksResponse{Tasks: items})
|
||||
}
|
||||
|
||||
// GetPendingChatTask returns the most recent in-flight task (queued / dispatched
|
||||
// / running) for a chat session. The frontend polls this on mount / session
|
||||
// switch so pending UI state survives refresh and reopen.
|
||||
func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
|
||||
ID: parseUUID(sessionID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "chat session not found")
|
||||
return
|
||||
}
|
||||
if uuidToString(session.CreatorID) != userID {
|
||||
writeError(w, http.StatusForbidden, "not your chat session")
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.Queries.GetPendingChatTask(r.Context(), parseUUID(sessionID))
|
||||
if err != nil {
|
||||
// No in-flight task — return an empty object, not an error.
|
||||
writeJSON(w, http.StatusOK, PendingChatTaskResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, PendingChatTaskResponse{
|
||||
TaskID: uuidToString(task.ID),
|
||||
Status: task.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task cancellation (user-facing, with ownership check)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,14 +482,16 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ChatSessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
// Only populated by list endpoints — single-session fetches return false.
|
||||
HasUnread bool `json:"has_unread"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ChatMessageResponse struct {
|
||||
|
||||
@@ -262,7 +262,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
// assignee — the user is continuing a member-to-member conversation.
|
||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
|
||||
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
|
||||
!h.isReplyToMemberThread(parentComment, comment.Content, issue) {
|
||||
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
|
||||
// Resolve thread root: if the comment is a reply, agent should reply
|
||||
// to the thread root (matching frontend behavior where all replies
|
||||
// in a thread share the same top-level parent).
|
||||
@@ -325,7 +325,9 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
|
||||
// in the reply, still triggers on_comment as expected.
|
||||
// If the parent (thread root) itself @mentions the assignee, the thread is
|
||||
// considered a conversation with the agent, so replies are allowed to trigger.
|
||||
func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool {
|
||||
// If the assigned agent has already replied in the thread, the member is
|
||||
// conversing with the agent, so replies are allowed to trigger.
|
||||
func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment, content string, issue db.Issue) bool {
|
||||
if parent == nil {
|
||||
return false // Not a reply — normal top-level comment
|
||||
}
|
||||
@@ -333,7 +335,8 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
|
||||
return false // Thread started by an agent — allow trigger
|
||||
}
|
||||
// Thread was started by a member. Suppress on_comment unless the reply
|
||||
// or the parent explicitly @mentions the assignee agent.
|
||||
// or the parent explicitly @mentions the assignee agent, or the agent
|
||||
// has already participated in this thread.
|
||||
if !issue.AssigneeID.Valid {
|
||||
return true // No assignee to mention
|
||||
}
|
||||
@@ -351,7 +354,18 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
|
||||
return false // Assignee mentioned in thread root — allow trigger
|
||||
}
|
||||
}
|
||||
return true // Reply to member thread without mentioning agent — suppress
|
||||
// Check if the assigned agent has already replied in this thread —
|
||||
// if so, the member is continuing a conversation with the agent.
|
||||
if h.Queries != nil {
|
||||
hasReplied, err := h.Queries.HasAgentRepliedInThread(ctx, db.HasAgentRepliedInThreadParams{
|
||||
ParentID: parent.ID,
|
||||
AgentID: issue.AssigneeID,
|
||||
})
|
||||
if err == nil && hasReplied {
|
||||
return false // Agent participated in thread — allow trigger
|
||||
}
|
||||
}
|
||||
return true // Reply to member thread without agent participation — suppress
|
||||
}
|
||||
|
||||
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
|
||||
|
||||
@@ -117,6 +117,7 @@ type DaemonRegisterRequest struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
CLIVersion string `json:"cli_version"` // multica CLI version
|
||||
LaunchedBy string `json:"launched_by"` // "desktop" when spawned by the Electron app
|
||||
Runtimes []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -200,6 +201,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"version": runtime.Version,
|
||||
"cli_version": req.CLIVersion,
|
||||
"launched_by": req.LaunchedBy,
|
||||
})
|
||||
|
||||
registered, err := h.Queries.UpsertAgentRuntime(r.Context(), db.UpsertAgentRuntimeParams{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -205,7 +206,7 @@ func TestIsReplyToMemberThread(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := h.isReplyToMemberThread(tt.parent, tt.content, issue)
|
||||
got := h.isReplyToMemberThread(context.Background(), tt.parent, tt.content, issue)
|
||||
if got != tt.want {
|
||||
t.Errorf("isReplyToMemberThread() = %v, want %v", got, tt.want)
|
||||
}
|
||||
@@ -233,7 +234,7 @@ func TestOnCommentTriggerDecision(t *testing.T) {
|
||||
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
|
||||
shouldTrigger := func(parent *db.Comment, content string) bool {
|
||||
return !h.commentMentionsOthersButNotAssignee(content, issue) &&
|
||||
!h.isReplyToMemberThread(parent, content, issue)
|
||||
!h.isReplyToMemberThread(context.Background(), parent, content, issue)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -10,24 +10,39 @@ import (
|
||||
"github.com/lmittmann/tint"
|
||||
)
|
||||
|
||||
// Init initializes the global slog logger with colored terminal output.
|
||||
// Reads LOG_LEVEL env var (debug, info, warn, error). Default: debug.
|
||||
// isTerminal reports whether the given file descriptor is connected to a
|
||||
// terminal. Used to suppress ANSI color escapes when stderr is redirected
|
||||
// to a file (e.g. daemon.log), so log files stay clean.
|
||||
func isTerminal(f *os.File) bool {
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// Init initializes the global slog logger. Colors are enabled when stderr
|
||||
// is a terminal and disabled otherwise. Reads LOG_LEVEL env var (debug,
|
||||
// info, warn, error). Default: debug.
|
||||
func Init() {
|
||||
level := parseLevel(os.Getenv("LOG_LEVEL"))
|
||||
handler := tint.NewHandler(os.Stderr, &tint.Options{
|
||||
Level: level,
|
||||
TimeFormat: "15:04:05.000",
|
||||
NoColor: !isTerminal(os.Stderr),
|
||||
})
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
|
||||
// NewLogger creates a named slog logger with colored terminal output.
|
||||
// Useful for standalone processes (daemon, migrate) that want a component prefix.
|
||||
// NewLogger creates a named slog logger. Colors follow the same
|
||||
// TTY-detection rule as Init. Useful for standalone processes (daemon,
|
||||
// migrate) that want a component prefix.
|
||||
func NewLogger(component string) *slog.Logger {
|
||||
level := parseLevel(os.Getenv("LOG_LEVEL"))
|
||||
handler := tint.NewHandler(os.Stderr, &tint.Options{
|
||||
Level: level,
|
||||
TimeFormat: "15:04:05.000",
|
||||
NoColor: !isTerminal(os.Stderr),
|
||||
})
|
||||
return slog.New(handler).With("component", component)
|
||||
}
|
||||
|
||||
@@ -307,6 +307,14 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
|
||||
TaskID: task.ID,
|
||||
}); err != nil {
|
||||
slog.Error("failed to save assistant chat message", "task_id", util.UUIDToString(task.ID), "error", err)
|
||||
} else {
|
||||
// Event-driven unread: stamp unread_since on the first unread
|
||||
// assistant message. No-op if the session already has unread.
|
||||
// If the user is actively viewing the session, the frontend's
|
||||
// auto-mark-read effect will clear this within a tick.
|
||||
if err := s.Queries.SetUnreadSinceIfNull(ctx, task.ChatSessionID); err != nil {
|
||||
slog.Warn("failed to set unread_since", "chat_session_id", util.UUIDToString(task.ChatSessionID), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Queries.UpdateChatSessionSession(ctx, db.UpdateChatSessionSessionParams{
|
||||
|
||||
2
server/migrations/040_chat_unread_since.down.sql
Normal file
2
server/migrations/040_chat_unread_since.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_agent_task_queue_chat_pending;
|
||||
ALTER TABLE chat_session DROP COLUMN unread_since;
|
||||
16
server/migrations/040_chat_unread_since.up.sql
Normal file
16
server/migrations/040_chat_unread_since.up.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Event-driven unread tracking for chat sessions.
|
||||
--
|
||||
-- Semantics: unread_since is the timestamp of the first unread assistant
|
||||
-- message. It stays NULL while the session has no unread. It's SET when
|
||||
-- an assistant reply lands and the column was NULL. It's RESET to NULL
|
||||
-- when the user marks the session as read. Existing rows start as NULL,
|
||||
-- meaning "no unread to track" — historic chats are not mass-flagged.
|
||||
ALTER TABLE chat_session ADD COLUMN unread_since TIMESTAMPTZ;
|
||||
|
||||
-- GetPendingChatTask runs on every session open / switch and filters by
|
||||
-- chat_session_id + in-flight status + orders by created_at. A partial
|
||||
-- index on the in-flight subset keeps that query cheap as the queue grows.
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_task_queue_chat_pending
|
||||
ON agent_task_queue (chat_session_id, created_at DESC)
|
||||
WHERE chat_session_id IS NOT NULL
|
||||
AND status IN ('queued', 'dispatched', 'running');
|
||||
@@ -56,7 +56,7 @@ func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessagePa
|
||||
const createChatSession = `-- name: CreateChatSession :one
|
||||
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since
|
||||
`
|
||||
|
||||
type CreateChatSessionParams struct {
|
||||
@@ -85,6 +85,7 @@ func (q *Queries) CreateChatSession(ctx context.Context, arg CreateChatSessionPa
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -152,7 +153,7 @@ func (q *Queries) GetChatMessage(ctx context.Context, id pgtype.UUID) (ChatMessa
|
||||
}
|
||||
|
||||
const getChatSession = `-- name: GetChatSession :one
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since FROM chat_session
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -170,12 +171,13 @@ func (q *Queries) GetChatSession(ctx context.Context, id pgtype.UUID) (ChatSessi
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatSessionInWorkspace = `-- name: GetChatSessionInWorkspace :one
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since FROM chat_session
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -198,6 +200,7 @@ func (q *Queries) GetChatSessionInWorkspace(ctx context.Context, arg GetChatSess
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -221,10 +224,33 @@ func (q *Queries) GetLastChatTaskSession(ctx context.Context, chatSessionID pgty
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPendingChatTask = `-- name: GetPendingChatTask :one
|
||||
SELECT id, status FROM agent_task_queue
|
||||
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetPendingChatTaskRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Returns the most recent in-flight task for a chat session, if any.
|
||||
// Used by the frontend to recover pending state after refresh / reopen.
|
||||
func (q *Queries) GetPendingChatTask(ctx context.Context, chatSessionID pgtype.UUID) (GetPendingChatTaskRow, error) {
|
||||
row := q.db.QueryRow(ctx, getPendingChatTask, chatSessionID)
|
||||
var i GetPendingChatTaskRow
|
||||
err := row.Scan(&i.ID, &i.Status)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAllChatSessionsByCreator = `-- name: ListAllChatSessionsByCreator :many
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2
|
||||
ORDER BY updated_at DESC
|
||||
SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_id, cs.work_dir, cs.status, cs.created_at, cs.updated_at, cs.unread_since,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
|
||||
ORDER BY cs.updated_at DESC
|
||||
`
|
||||
|
||||
type ListAllChatSessionsByCreatorParams struct {
|
||||
@@ -232,15 +258,30 @@ type ListAllChatSessionsByCreatorParams struct {
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ChatSession, error) {
|
||||
type ListAllChatSessionsByCreatorRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
SessionID pgtype.Text `json:"session_id"`
|
||||
WorkDir pgtype.Text `json:"work_dir"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
UnreadSince pgtype.Timestamptz `json:"unread_since"`
|
||||
HasUnread bool `json:"has_unread"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ListAllChatSessionsByCreatorRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAllChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatSession{}
|
||||
items := []ListAllChatSessionsByCreatorRow{}
|
||||
for rows.Next() {
|
||||
var i ChatSession
|
||||
var i ListAllChatSessionsByCreatorRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
@@ -252,6 +293,8 @@ func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllC
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
&i.HasUnread,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -297,9 +340,11 @@ func (q *Queries) ListChatMessages(ctx context.Context, chatSessionID pgtype.UUI
|
||||
}
|
||||
|
||||
const listChatSessionsByCreator = `-- name: ListChatSessionsByCreator :many
|
||||
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2 AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_id, cs.work_dir, cs.status, cs.created_at, cs.updated_at, cs.unread_since,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
|
||||
ORDER BY cs.updated_at DESC
|
||||
`
|
||||
|
||||
type ListChatSessionsByCreatorParams struct {
|
||||
@@ -307,15 +352,33 @@ type ListChatSessionsByCreatorParams struct {
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSessionsByCreatorParams) ([]ChatSession, error) {
|
||||
type ListChatSessionsByCreatorRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
Title string `json:"title"`
|
||||
SessionID pgtype.Text `json:"session_id"`
|
||||
WorkDir pgtype.Text `json:"work_dir"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
UnreadSince pgtype.Timestamptz `json:"unread_since"`
|
||||
HasUnread bool `json:"has_unread"`
|
||||
}
|
||||
|
||||
// Returns active sessions with a boolean unread flag. Unread is strictly
|
||||
// per-session: either the user has uncleared assistant replies in this
|
||||
// session or they don't. Counting messages would be misleading.
|
||||
func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSessionsByCreatorParams) ([]ListChatSessionsByCreatorRow, error) {
|
||||
rows, err := q.db.Query(ctx, listChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatSession{}
|
||||
items := []ListChatSessionsByCreatorRow{}
|
||||
for rows.Next() {
|
||||
var i ChatSession
|
||||
var i ListChatSessionsByCreatorRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
@@ -327,6 +390,8 @@ func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSes
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
&i.HasUnread,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -338,6 +403,74 @@ func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSes
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPendingChatTasksByCreator = `-- name: ListPendingChatTasksByCreator :many
|
||||
SELECT atq.id AS task_id, atq.status, atq.chat_session_id
|
||||
FROM agent_task_queue atq
|
||||
JOIN chat_session cs ON cs.id = atq.chat_session_id
|
||||
WHERE cs.workspace_id = $1
|
||||
AND cs.creator_id = $2
|
||||
AND atq.status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY atq.created_at DESC
|
||||
`
|
||||
|
||||
type ListPendingChatTasksByCreatorParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
}
|
||||
|
||||
type ListPendingChatTasksByCreatorRow struct {
|
||||
TaskID pgtype.UUID `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// Aggregate view of all in-flight chat tasks owned by a given creator in a
|
||||
// workspace. Drives the FAB's "running" indicator when the chat window is
|
||||
// closed and no single session's query is active.
|
||||
func (q *Queries) ListPendingChatTasksByCreator(ctx context.Context, arg ListPendingChatTasksByCreatorParams) ([]ListPendingChatTasksByCreatorRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPendingChatTasksByCreator, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListPendingChatTasksByCreatorRow{}
|
||||
for rows.Next() {
|
||||
var i ListPendingChatTasksByCreatorRow
|
||||
if err := rows.Scan(&i.TaskID, &i.Status, &i.ChatSessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markChatSessionRead = `-- name: MarkChatSessionRead :exec
|
||||
UPDATE chat_session SET unread_since = NULL
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
// Clears unread_since, dropping the session's unread count to 0.
|
||||
func (q *Queries) MarkChatSessionRead(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, markChatSessionRead, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const setUnreadSinceIfNull = `-- name: SetUnreadSinceIfNull :exec
|
||||
UPDATE chat_session SET unread_since = now()
|
||||
WHERE id = $1 AND unread_since IS NULL
|
||||
`
|
||||
|
||||
// Atomically stamps the first unread assistant message's arrival time.
|
||||
// No-op if the session is already in "has unread" state — keeps the earliest
|
||||
// unread boundary stable across multiple incoming replies.
|
||||
func (q *Queries) SetUnreadSinceIfNull(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, setUnreadSinceIfNull, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const touchChatSession = `-- name: TouchChatSession :exec
|
||||
UPDATE chat_session SET updated_at = now()
|
||||
WHERE id = $1
|
||||
@@ -367,7 +500,7 @@ func (q *Queries) UpdateChatSessionSession(ctx context.Context, arg UpdateChatSe
|
||||
const updateChatSessionTitle = `-- name: UpdateChatSessionTitle :one
|
||||
UPDATE chat_session SET title = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at
|
||||
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since
|
||||
`
|
||||
|
||||
type UpdateChatSessionTitleParams struct {
|
||||
@@ -389,6 +522,7 @@ func (q *Queries) UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSess
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UnreadSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -153,6 +153,26 @@ func (q *Queries) HasAgentCommentedSince(ctx context.Context, arg HasAgentCommen
|
||||
return commented, err
|
||||
}
|
||||
|
||||
const hasAgentRepliedInThread = `-- name: HasAgentRepliedInThread :one
|
||||
SELECT count(*) > 0 AS has_replied FROM comment
|
||||
WHERE parent_id = $1 AND author_type = 'agent' AND author_id = $2
|
||||
`
|
||||
|
||||
type HasAgentRepliedInThreadParams struct {
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
}
|
||||
|
||||
// Returns true if the given agent has posted a reply in the thread rooted at
|
||||
// the specified parent comment. Used to detect agent participation in a
|
||||
// member-started thread so that follow-up member replies still trigger the agent.
|
||||
func (q *Queries) HasAgentRepliedInThread(ctx context.Context, arg HasAgentRepliedInThreadParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, hasAgentRepliedInThread, arg.ParentID, arg.AgentID)
|
||||
var has_replied bool
|
||||
err := row.Scan(&has_replied)
|
||||
return has_replied, err
|
||||
}
|
||||
|
||||
const listComments = `-- name: ListComments :many
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
|
||||
@@ -116,6 +116,7 @@ type ChatSession struct {
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
UnreadSince pgtype.Timestamptz `json:"unread_since"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
|
||||
@@ -12,14 +12,21 @@ SELECT * FROM chat_session
|
||||
WHERE id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: ListChatSessionsByCreator :many
|
||||
SELECT * FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2 AND status = 'active'
|
||||
ORDER BY updated_at DESC;
|
||||
-- Returns active sessions with a boolean unread flag. Unread is strictly
|
||||
-- per-session: either the user has uncleared assistant replies in this
|
||||
-- session or they don't. Counting messages would be misleading.
|
||||
SELECT cs.*,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
|
||||
ORDER BY cs.updated_at DESC;
|
||||
|
||||
-- name: ListAllChatSessionsByCreator :many
|
||||
SELECT * FROM chat_session
|
||||
WHERE workspace_id = $1 AND creator_id = $2
|
||||
ORDER BY updated_at DESC;
|
||||
SELECT cs.*,
|
||||
(cs.unread_since IS NOT NULL)::bool AS has_unread
|
||||
FROM chat_session cs
|
||||
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
|
||||
ORDER BY cs.updated_at DESC;
|
||||
|
||||
-- name: UpdateChatSessionTitle :one
|
||||
UPDATE chat_session SET title = $2, updated_at = now()
|
||||
@@ -62,3 +69,35 @@ SELECT session_id, work_dir FROM agent_task_queue
|
||||
WHERE chat_session_id = $1 AND status = 'completed' AND session_id IS NOT NULL
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetPendingChatTask :one
|
||||
-- Returns the most recent in-flight task for a chat session, if any.
|
||||
-- Used by the frontend to recover pending state after refresh / reopen.
|
||||
SELECT id, status FROM agent_task_queue
|
||||
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListPendingChatTasksByCreator :many
|
||||
-- Aggregate view of all in-flight chat tasks owned by a given creator in a
|
||||
-- workspace. Drives the FAB's "running" indicator when the chat window is
|
||||
-- closed and no single session's query is active.
|
||||
SELECT atq.id AS task_id, atq.status, atq.chat_session_id
|
||||
FROM agent_task_queue atq
|
||||
JOIN chat_session cs ON cs.id = atq.chat_session_id
|
||||
WHERE cs.workspace_id = $1
|
||||
AND cs.creator_id = $2
|
||||
AND atq.status IN ('queued', 'dispatched', 'running')
|
||||
ORDER BY atq.created_at DESC;
|
||||
|
||||
-- name: MarkChatSessionRead :exec
|
||||
-- Clears unread_since, dropping the session's unread count to 0.
|
||||
UPDATE chat_session SET unread_since = NULL
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: SetUnreadSinceIfNull :exec
|
||||
-- Atomically stamps the first unread assistant message's arrival time.
|
||||
-- No-op if the session is already in "has unread" state — keeps the earliest
|
||||
-- unread boundary stable across multiple incoming replies.
|
||||
UPDATE chat_session SET unread_since = now()
|
||||
WHERE id = $1 AND unread_since IS NULL;
|
||||
|
||||
@@ -53,5 +53,12 @@ SELECT EXISTS (
|
||||
AND created_at >= @since
|
||||
) AS commented;
|
||||
|
||||
-- name: HasAgentRepliedInThread :one
|
||||
-- Returns true if the given agent has posted a reply in the thread rooted at
|
||||
-- the specified parent comment. Used to detect agent participation in a
|
||||
-- member-started thread so that follow-up member replies still trigger the agent.
|
||||
SELECT count(*) > 0 AS has_replied FROM comment
|
||||
WHERE parent_id = @parent_id AND author_type = 'agent' AND author_id = @agent_id;
|
||||
|
||||
-- name: DeleteComment :exec
|
||||
DELETE FROM comment WHERE id = $1;
|
||||
|
||||
@@ -59,8 +59,9 @@ const (
|
||||
EventSkillDeleted = "skill:deleted"
|
||||
|
||||
// Chat events
|
||||
EventChatMessage = "chat:message"
|
||||
EventChatDone = "chat:done"
|
||||
EventChatMessage = "chat:message"
|
||||
EventChatDone = "chat:done"
|
||||
EventChatSessionRead = "chat:session_read"
|
||||
|
||||
// Project events
|
||||
EventProjectCreated = "project:created"
|
||||
|
||||
@@ -74,6 +74,12 @@ type ChatDonePayload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatSessionReadPayload is broadcast when the creator marks a session as read.
|
||||
// Fires to other devices so their unread counts stay in sync.
|
||||
type ChatSessionReadPayload struct {
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// HeartbeatPayload is sent periodically from daemon to server.
|
||||
type HeartbeatPayload struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
|
||||
Reference in New Issue
Block a user