fix(sidebar): narrow user popover width

Merge conflict resolution: keep narrower w-48 popover from PR #1045.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yushen
2026-04-15 12:22:31 +08:00
9 changed files with 382 additions and 24 deletions

View File

@@ -13,6 +13,7 @@
"preview": "electron-vite preview",
"package": "pnpm run bundle-cli && electron-builder",
"lint": "eslint .",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
@@ -47,6 +48,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -18,6 +18,7 @@ import { join } from "path";
import { homedir } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
const DEFAULT_HEALTH_PORT = 19514;
const POLL_INTERVAL_MS = 5_000;
@@ -39,6 +40,11 @@ let getMainWindow: () => BrowserWindow | null = () => null;
let operationInProgress = false;
let cachedCliBinary: string | null | undefined = undefined;
let cliResolvePromise: Promise<string | null> | null = null;
let cachedCliBinaryVersion: string | null | undefined = undefined;
// Set when a CLI version mismatch was detected but the running daemon is
// busy executing tasks. The poll loop retries the check on each tick and
// fires the restart once active_task_count drops to 0.
let pendingVersionRestart = false;
let targetApiBaseUrl: string | null = null;
let activeProfile: ActiveProfile | null = null;
@@ -132,6 +138,8 @@ interface HealthPayload {
daemon_id?: string;
device_name?: string;
server_url?: string;
cli_version?: string;
active_task_count?: number;
agents?: string[];
workspaces?: unknown[];
}
@@ -361,6 +369,91 @@ async function resolveCliBinary(): Promise<string | null> {
}
}
/**
* Reads the version of the currently resolved CLI binary by invoking
* `multica version --output json`. Cached for the process lifetime — the
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
* Returns null on any failure (unknown `go` at bundle time, broken binary,
* etc.) so callers can fail open.
*/
async function getCliBinaryVersion(): Promise<string | null> {
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
const bin = await resolveCliBinary();
if (!bin) {
cachedCliBinaryVersion = null;
return null;
}
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
cachedCliBinaryVersion = parsed.version ?? null;
} catch (err) {
console.warn("[daemon] failed to read CLI binary version:", err);
cachedCliBinaryVersion = null;
}
return cachedCliBinaryVersion;
}
/**
* Compares the running daemon's `cli_version` against the CLI binary we
* would use to spawn a new one, and restarts only when safe. The decision
* logic itself is in `version-decision.ts` (pure, unit-tested); this
* wrapper handles the async plumbing and side effects.
*
* Restart is only fired when ALL of:
* - a daemon is actually running on the active profile's port
* - both sides report a version and the strings differ
* - `active_task_count` is 0 (no in-flight agent work would be killed)
*
* On a confirmed mismatch while the daemon is busy, `pendingVersionRestart`
* is set; the poll loop retries this function on each 5s tick and will fire
* the restart as soon as the daemon drains.
*/
async function ensureRunningDaemonVersionMatches(): Promise<
"restarted" | "deferred" | "ok" | "not_running"
> {
const active = await ensureActiveProfile();
const running = await fetchHealthAtPort(active.port);
const bundled = await getCliBinaryVersion();
const action = decideVersionAction(bundled, running);
switch (action) {
case "not_running":
pendingVersionRestart = false;
return "not_running";
case "ok":
pendingVersionRestart = false;
return "ok";
case "defer": {
if (!pendingVersionRestart) {
const activeTasks = running?.active_task_count ?? 0;
console.log(
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`,
);
}
pendingVersionRestart = true;
return "deferred";
}
case "restart":
console.log(
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`,
);
pendingVersionRestart = false;
await restartDaemon();
return "restarted";
}
}
/**
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
@@ -582,6 +675,10 @@ async function pollOnce(): Promise<void> {
const status = await fetchHealth();
currentState = status.state;
sendStatus(status);
// Retry a deferred version-mismatch restart once the daemon drains.
if (pendingVersionRestart && status.state === "running") {
void ensureRunningDaemonVersionMatches();
}
}
function startPolling(): void {
@@ -738,6 +835,9 @@ export function setupDaemonManager(
ipcMain.handle("daemon:retry-install", async () => {
cachedCliBinary = undefined;
cliResolvePromise = null;
// A retry-install may land a new CLI at a different version; drop the
// cached version string so the next check re-reads the binary.
cachedCliBinaryVersion = undefined;
await bootstrapCli();
});
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
@@ -755,7 +855,12 @@ export function setupDaemonManager(
const bin = await resolveCliBinary();
if (!bin) return;
const health = await fetchHealth();
if (health.state === "running") return;
if (health.state === "running") {
// Daemon is up but may be running an older CLI than the one we just
// bundled. Restart it so the new binary actually takes effect.
await ensureRunningDaemonVersionMatches();
return;
}
await startDaemon();
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { decideVersionAction } from "./version-decision";
describe("decideVersionAction", () => {
it("returns not_running when health payload is null", () => {
expect(decideVersionAction("v1.0.0", null)).toBe("not_running");
});
it("returns not_running when status is not 'running'", () => {
expect(
decideVersionAction("v1.0.0", { status: "stopped", cli_version: "v1.0.0" }),
).toBe("not_running");
});
it("returns ok when bundled version is unknown (fail safe)", () => {
expect(
decideVersionAction(null, {
status: "running",
cli_version: "v1.0.0",
active_task_count: 0,
}),
).toBe("ok");
});
it("returns ok when running daemon does not report cli_version (older daemon)", () => {
expect(
decideVersionAction("v1.0.0", {
status: "running",
active_task_count: 0,
}),
).toBe("ok");
});
it("returns ok when versions match exactly", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.3",
active_task_count: 5,
}),
).toBe("ok");
});
it("returns restart when versions differ and daemon is idle", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
active_task_count: 0,
}),
).toBe("restart");
});
it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
}),
).toBe("restart");
});
it("returns defer when versions differ but daemon is busy", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
active_task_count: 2,
}),
).toBe("defer");
});
it("transitions defer → restart as tasks drain", () => {
// Same bundled version across three observations while the daemon ages.
const bundled = "v2.0.0";
const base = { status: "running", cli_version: "v1.9.0" } as const;
expect(
decideVersionAction(bundled, { ...base, active_task_count: 3 }),
).toBe("defer");
expect(
decideVersionAction(bundled, { ...base, active_task_count: 1 }),
).toBe("defer");
expect(
decideVersionAction(bundled, { ...base, active_task_count: 0 }),
).toBe("restart");
});
});

View File

@@ -0,0 +1,37 @@
// Pure decision logic for the daemon version-check flow. Kept in its own
// module so it can be unit-tested without mocking Electron, execFile, or
// the HTTP health probe.
export interface VersionCheckHealth {
status?: string;
cli_version?: string;
active_task_count?: number;
}
export type VersionAction = "restart" | "defer" | "ok" | "not_running";
/**
* Decides what the daemon-manager should do given the currently-resolved
* bundled CLI version and the latest /health payload.
*
* not_running: no daemon is up, nothing to do
* ok: versions match, OR either side is unknown (fail safe)
* defer: versions differ but the daemon is busy — wait for drain
* restart: versions differ and the daemon is idle — safe to restart
*
* Pure function: no I/O, no side effects, no module state.
*/
export function decideVersionAction(
bundled: string | null,
running: VersionCheckHealth | null,
): VersionAction {
if (!running || running.status !== "running") return "not_running";
const runningVersion = running.cli_version;
if (!bundled || !runningVersion) return "ok";
if (runningVersion === bundled) return "ok";
const activeTasks = running.active_task_count ?? 0;
if (activeTasks > 0) return "defer";
return "restart";
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["src/**/*.test.ts"],
environment: "node",
passWithNoTests: true,
},
});

3
pnpm-lock.yaml generated
View File

@@ -181,6 +181,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
apps/docs:
dependencies:

View File

@@ -39,6 +39,7 @@ type Daemon struct {
cancelFunc context.CancelFunc // set by Run(); called by triggerRestart
restartBinary string // non-empty after a successful update; path to the new binary
updating atomic.Bool // prevents concurrent update attempts
activeTasks atomic.Int64 // number of tasks currently in handleTask; exposed via /health
}
// New creates a new Daemon instance.
@@ -649,8 +650,10 @@ func (d *Daemon) pollLoop(ctx context.Context) error {
}
d.logger.Info("task received", "task", shortID(task.ID), "target", taskTarget)
wg.Add(1)
d.activeTasks.Add(1)
go func(t Task) {
defer wg.Done()
defer d.activeTasks.Add(-1)
defer func() { <-sem }()
d.handleTask(ctx, t)
}(*task)

View File

@@ -14,14 +14,16 @@ import (
// HealthResponse is returned by the daemon's local health endpoint.
type HealthResponse struct {
Status string `json:"status"`
PID int `json:"pid"`
Uptime string `json:"uptime"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
ServerURL string `json:"server_url"`
Agents []string `json:"agents"`
Workspaces []healthWorkspace `json:"workspaces"`
Status string `json:"status"`
PID int `json:"pid"`
Uptime string `json:"uptime"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
ServerURL string `json:"server_url"`
CLIVersion string `json:"cli_version"`
ActiveTaskCount int64 `json:"active_task_count"`
Agents []string `json:"agents"`
Workspaces []healthWorkspace `json:"workspaces"`
}
type healthWorkspace struct {
@@ -49,11 +51,10 @@ type repoCheckoutRequest struct {
TaskID string `json:"task_id"`
}
// 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) {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
// healthHandler returns the /health HTTP handler. Extracted from serveHealth
// so tests can exercise it without spinning up a listener.
func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
var wsList []healthWorkspace
for id, ws := range d.workspaces {
@@ -70,19 +71,28 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
}
resp := HealthResponse{
Status: "running",
PID: os.Getpid(),
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
DaemonID: d.cfg.DaemonID,
DeviceName: d.cfg.DeviceName,
ServerURL: d.cfg.ServerBaseURL,
Agents: agents,
Workspaces: wsList,
Status: "running",
PID: os.Getpid(),
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
DaemonID: d.cfg.DaemonID,
DeviceName: d.cfg.DeviceName,
ServerURL: d.cfg.ServerBaseURL,
CLIVersion: d.cfg.CLIVersion,
ActiveTaskCount: d.activeTasks.Load(),
Agents: agents,
Workspaces: wsList,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
}
}
// 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) {
mux := http.NewServeMux()
mux.HandleFunc("/health", d.healthHandler(startedAt))
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {

View File

@@ -0,0 +1,100 @@
package daemon
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) {
t.Parallel()
d := &Daemon{
cfg: Config{
CLIVersion: "v9.9.9",
DaemonID: "daemon-test",
DeviceName: "dev",
ServerBaseURL: "http://localhost:8080",
},
workspaces: map[string]*workspaceState{},
logger: slog.Default(),
}
d.activeTasks.Store(3)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
d.healthHandler(time.Now()).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
// Decode into a raw map so the test locks in the exact wire-level JSON
// keys — the desktop TS client depends on snake_case (cli_version,
// active_task_count), so a silent struct-tag rename must fail here.
var raw map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode raw response: %v", err)
}
if got, want := raw["cli_version"], "v9.9.9"; got != want {
t.Errorf("cli_version key: got %v, want %q", got, want)
}
// JSON numbers decode to float64 through map[string]any.
if got, want := raw["active_task_count"], float64(3); got != want {
t.Errorf("active_task_count key: got %v, want %v", got, want)
}
if got, want := raw["status"], "running"; got != want {
t.Errorf("status key: got %v, want %q", got, want)
}
// Also round-trip into the typed struct as a separate check that the
// field values match, independent of key naming.
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode typed response: %v", err)
}
if resp.CLIVersion != "v9.9.9" {
t.Errorf("CLIVersion: got %q, want %q", resp.CLIVersion, "v9.9.9")
}
if resp.ActiveTaskCount != 3 {
t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount)
}
}
func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) {
t.Parallel()
d := &Daemon{
cfg: Config{CLIVersion: "v1.0.0"},
workspaces: map[string]*workspaceState{},
logger: slog.Default(),
}
handler := d.healthHandler(time.Now())
// Simulate the pollLoop increment/decrement protocol.
d.activeTasks.Add(1)
d.activeTasks.Add(1)
assertActiveTaskCount(t, handler, 2)
d.activeTasks.Add(-1)
assertActiveTaskCount(t, handler, 1)
d.activeTasks.Add(-1)
assertActiveTaskCount(t, handler, 0)
}
func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ActiveTaskCount != want {
t.Errorf("active_task_count: got %d, want %d", resp.ActiveTaskCount, want)
}
}