diff --git a/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png b/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png new file mode 100644 index 00000000..eaaff14c Binary files /dev/null and b/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png differ diff --git a/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png b/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png new file mode 100644 index 00000000..8d6794d9 Binary files /dev/null and b/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png differ diff --git a/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png b/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png new file mode 100644 index 00000000..ebb28fc0 Binary files /dev/null and b/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png differ diff --git a/docs/pr-screenshots/pr-6384/sidebar-desktop.png b/docs/pr-screenshots/pr-6384/sidebar-desktop.png new file mode 100644 index 00000000..9adca66b Binary files /dev/null and b/docs/pr-screenshots/pr-6384/sidebar-desktop.png differ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index e5d73794..8516a9c5 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -36,6 +36,7 @@ const autoRestartPollIntervalMs = 2500; const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); +const devServerRestartRequestFilePath = path.join(repoRoot, ".paperclip", "dev-server-restart-request.json"); const devServerStatusToken = mode === "dev" ? randomUUID() : null; const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; @@ -70,6 +71,7 @@ const ignoredDirectoryNames = new Set([ ]); const ignoredRelativePaths = new Set([ + ".paperclip/dev-server-restart-request.json", ".paperclip/dev-server-status.json", ]); @@ -348,6 +350,13 @@ function writeDevServerStatus() { function clearDevServerStatus() { if (mode !== "dev") return; rmSync(devServerStatusFilePath, { force: true }); + rmSync(devServerRestartRequestFilePath, { force: true }); +} + +function consumeDevServerRestartRequest() { + if (mode !== "dev" || !existsSync(devServerRestartRequestFilePath)) return false; + rmSync(devServerRestartRequestFilePath, { force: true }); + return true; } async function updateDevServiceRecord(extra?: Record) { @@ -633,7 +642,8 @@ async function startServerChild() { async function maybeAutoRestartChild() { if (mode !== "dev" || restartInFlight || !child) return; - if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return; + const manualRestartRequested = consumeDevServerRestartRequest(); + if (!manualRestartRequested && dirtyPaths.size === 0 && pendingMigrations.length === 0) return; restartInFlight = true; let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null; @@ -645,11 +655,15 @@ async function maybeAutoRestartChild() { } const devServer = health?.devServer; - if (!devServer?.enabled || devServer.autoRestartEnabled !== true) { + if (!devServer?.enabled) { restartInFlight = false; return; } - if ((devServer.activeRunCount ?? 0) > 0) { + if (!manualRestartRequested && devServer.autoRestartEnabled !== true) { + restartInFlight = false; + return; + } + if (!manualRestartRequested && (devServer.activeRunCount ?? 0) > 0) { restartInFlight = false; return; } diff --git a/server/src/__tests__/dev-server-status.test.ts b/server/src/__tests__/dev-server-status.test.ts index 52eef387..052eacad 100644 --- a/server/src/__tests__/dev-server-status.test.ts +++ b/server/src/__tests__/dev-server-status.test.ts @@ -1,8 +1,13 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { + getDevServerRestartRequestFilePath, + readPersistedDevServerStatus, + toDevServerHealthStatus, + writeDevServerRestartRequest, +} from "../dev-server-status.js"; const tempDirs = []; @@ -73,4 +78,26 @@ describe("dev server status helpers", () => { expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull(); }); + + it("writes restart requests next to the persisted status file", () => { + const filePath = createTempStatusFile({ + dirty: true, + changedPathsSample: ["server/src/app.ts"], + pendingMigrations: [], + }); + + const env = { PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath }; + expect(writeDevServerRestartRequest({ + requestedAt: "2026-03-20T12:05:00.000Z", + reason: "manual_restart_now", + }, env)).toBe(true); + + const requestPath = getDevServerRestartRequestFilePath(env); + expect(requestPath).toBe(path.join(path.dirname(filePath), "dev-server-restart-request.json")); + expect(requestPath && existsSync(requestPath)).toBe(true); + expect(JSON.parse(readFileSync(requestPath!, "utf8"))).toEqual({ + requestedAt: "2026-03-20T12:05:00.000Z", + reason: "manual_restart_now", + }); + }); }); diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts index 7c768a35..536ef66c 100644 --- a/server/src/__tests__/health-dev-server-token.test.ts +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import express from "express"; @@ -126,3 +126,80 @@ describe("GET /health dev-server supervisor access", () => { } }); }); + +describe("POST /health/dev-server/restart", () => { + it("records a manual restart request for the dev runner", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + + try { + const app = express(); + app.use("/health", healthRoutes(undefined)); + + const res = await request(app).post("/health/dev-server/restart"); + + expect(res.status).toBe(202); + expect(res.body).toEqual({ status: "restart_requested" }); + + const requestPath = path.join( + path.dirname(process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE), + "dev-server-restart-request.json", + ); + expect(existsSync(requestPath)).toBe(true); + expect(JSON.parse(readFileSync(requestPath, "utf8"))).toMatchObject({ + reason: "manual_restart_now", + }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + } + }); + + it("rejects unauthenticated manual restarts in authenticated mode", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + }); + + try { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(undefined, { + deploymentMode: "authenticated", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }), + ); + + const res = await request(app).post("/health/dev-server/restart"); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "board_access_required" }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + } + }); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 963d3cd1..0c198488 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -42,6 +42,7 @@ async function createApp( jobDeps?: unknown; toolDeps?: unknown; bridgeDeps?: unknown; + captureJsonContext?: (context: unknown, body: unknown) => void; } = {}, ) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ @@ -56,6 +57,16 @@ async function createApp( const app = express(); app.use(express.json()); + if (routeOverrides.captureJsonContext) { + app.use((_req, res, next) => { + const originalJson = res.json.bind(res); + res.json = ((body: unknown) => { + routeOverrides.captureJsonContext?.((res as any).__errorContext, body); + return originalJson(body); + }) as typeof res.json; + next(); + }); + } app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); @@ -627,6 +638,40 @@ describe.sequential("plugin tool and bridge authz", () => { }); }); + it("attaches worker bridge errors to the HTTP logger context", async () => { + readyPlugin(); + const call = vi.fn().mockRejectedValue(new Error("missing source_objects column")); + const captured: Array<{ context: any; body: unknown }> = []; + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + captureJsonContext: (context, body) => { + captured.push({ context, body }); + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/data/source-objects`) + .send({ companyId: companyA }); + + expect(res.status).toBe(502); + expect(res.body).toMatchObject({ + code: "UNKNOWN", + message: "missing source_objects column", + }); + expect(captured.at(-1)?.context?.error).toMatchObject({ + message: "missing source_objects column", + details: { + pluginId, + pluginKey: "paperclip.example", + bridgeMethod: "getData", + dataKey: "source-objects", + bridgeCode: "UNKNOWN", + }, + }); + }); + it("rejects manual job triggers for non-admin board users", async () => { const scheduler = { triggerJob: vi.fn() }; const jobStore = { getJobByIdForPlugin: vi.fn() }; diff --git a/server/src/dev-server-status.ts b/server/src/dev-server-status.ts index ec78bfe8..2e20a441 100644 --- a/server/src/dev-server-status.ts +++ b/server/src/dev-server-status.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024; @@ -25,6 +26,31 @@ export type DevServerHealthStatus = { lastRestartAt: string | null; }; +export type DevServerRestartRequest = { + requestedAt: string; + reason: "manual_restart_now"; +}; + +export function getDevServerRestartRequestFilePath( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const statusFilePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim(); + if (!statusFilePath) return null; + return path.join(path.dirname(statusFilePath), "dev-server-restart-request.json"); +} + +export function writeDevServerRestartRequest( + request: DevServerRestartRequest, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const filePath = getDevServerRestartRequestFilePath(env); + if (!filePath) return false; + + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(request, null, 2)}\n`, "utf8"); + return true; +} + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 388585a5..80a2dd70 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; -import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { readPersistedDevServerStatus, toDevServerHealthStatus, writeDevServerRestartRequest } from "../dev-server-status.js"; import { logger } from "../middleware/logger.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; @@ -44,6 +44,40 @@ export function healthRoutes( ) { const router = Router(); + router.post("/dev-server/restart", async (req, res) => { + const actorType = "actor" in req ? req.actor?.type : null; + if (opts.deploymentMode === "authenticated" && actorType !== "board") { + res.status(403).json({ error: "board_access_required" }); + return; + } + + const persistedDevServerStatus = readPersistedDevServerStatus(); + if (!persistedDevServerStatus) { + res.status(404).json({ error: "dev_server_supervisor_unavailable" }); + return; + } + + const restartRequired = + persistedDevServerStatus.dirty || + persistedDevServerStatus.changedPathCount > 0 || + persistedDevServerStatus.pendingMigrations.length > 0; + if (!restartRequired) { + res.status(409).json({ error: "restart_not_required" }); + return; + } + + const written = writeDevServerRestartRequest({ + requestedAt: new Date().toISOString(), + reason: "manual_restart_now", + }); + if (!written) { + res.status(404).json({ error: "dev_server_supervisor_unavailable" }); + return; + } + + res.status(202).json({ status: "restart_requested" }); + }); + router.get("/", async (req, res) => { const actorType = "actor" in req ? req.actor?.type : null; const exposeFullDetails = shouldExposeFullHealthDetails( diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index f3b67423..6abb7c56 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -1021,6 +1021,34 @@ export function pluginRoutes( }; } + function attachPluginBridgeErrorContext( + req: Request, + res: Response, + err: unknown, + bridgeError: PluginBridgeErrorResponse, + metadata: Record, + ): void { + const rootError = err instanceof Error ? err : new Error(String(err)); + (res as any).__errorContext = { + error: { + message: bridgeError.message, + stack: rootError.stack, + name: rootError.name, + details: { + ...metadata, + bridgeCode: bridgeError.code, + bridgeDetails: bridgeError.details, + }, + }, + method: req.method, + url: req.originalUrl, + reqBody: req.body, + reqParams: req.params, + reqQuery: req.query, + }; + (res as any).err = rootError; + } + /** * POST /api/plugins/:pluginId/bridge/data * @@ -1072,6 +1100,11 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + }); res.status(502).json(bridgeError); return; } @@ -1098,6 +1131,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: body.key, + }); res.status(502).json(bridgeError); } }); @@ -1153,6 +1192,11 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + }); res.status(502).json(bridgeError); return; } @@ -1179,6 +1223,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: body.key, + }); res.status(502).json(bridgeError); } }); @@ -1235,6 +1285,12 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: key, + }); res.status(502).json(bridgeError); return; } @@ -1260,6 +1316,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: key, + }); res.status(502).json(bridgeError); } }); @@ -1312,6 +1374,12 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: key, + }); res.status(502).json(bridgeError); return; } @@ -1337,6 +1405,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: key, + }); res.status(502).json(bridgeError); } }); diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index e2725b20..453662c5 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -38,4 +38,15 @@ export const healthApi = { } return res.json(); }, + requestDevServerRestart: async (): Promise => { + const res = await fetch("/api/health/dev-server/restart", { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + const payload = await res.json().catch(() => null) as { error?: string } | null; + throw new Error(payload?.error ?? `Failed to request restart (${res.status})`); + } + }, }; diff --git a/ui/src/components/DevRestartBanner.test.tsx b/ui/src/components/DevRestartBanner.test.tsx new file mode 100644 index 00000000..b8a44b62 --- /dev/null +++ b/ui/src/components/DevRestartBanner.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DevRestartBanner } from "./DevRestartBanner"; + +const mockHealthApi = vi.hoisted(() => ({ + requestDevServerRestart: vi.fn(), +})); + +vi.mock("../api/health", () => ({ + healthApi: mockHealthApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +const devServer = { + enabled: true as const, + restartRequired: true, + reason: "backend_changes" as const, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + autoRestartEnabled: true, + activeRunCount: 1, + waitingForIdle: true, + lastRestartAt: "2026-03-20T11:30:00.000Z", +}; + +beforeEach(() => { + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.spyOn(window, "alert").mockImplementation(() => undefined); + mockHealthApi.requestDevServerRestart.mockResolvedValue(undefined); +}); + +afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.restoreAllMocks(); + vi.useRealTimers(); + mockHealthApi.requestDevServerRestart.mockReset(); +}); + +function render() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render()); + return container; +} + +describe("DevRestartBanner", () => { + it("confirms and requests an immediate restart while waiting for live runs", async () => { + const node = render(); + const button = [...node.querySelectorAll("button")] + .find((entry) => entry.textContent?.includes("Restart now")); + + expect(node.textContent).toContain("Waiting for 1 live run to finish"); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(window.confirm).toHaveBeenCalledWith("Restart Paperclip now? This may interrupt 1 live run."); + expect(mockHealthApi.requestDevServerRestart).toHaveBeenCalledTimes(1); + expect(node.textContent).toContain("Restart requested"); + }); + + it("does not request restart when confirmation is declined", async () => { + vi.mocked(window.confirm).mockReturnValue(false); + const node = render(); + const button = [...node.querySelectorAll("button")] + .find((entry) => entry.textContent?.includes("Restart now")); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(mockHealthApi.requestDevServerRestart).not.toHaveBeenCalled(); + }); + + it("re-enables the manual restart action when a request does not refresh the page", async () => { + vi.useFakeTimers(); + const node = render(); + const button = [...node.querySelectorAll("button")] + .find((entry) => entry.textContent?.includes("Restart now")) as HTMLButtonElement | undefined; + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(button?.disabled).toBe(true); + expect(node.textContent).toContain("Restart requested"); + + act(() => { + vi.advanceTimersByTime(30_000); + }); + + expect(button?.disabled).toBe(false); + expect(node.textContent).toContain("Restart now"); + }); +}); diff --git a/ui/src/components/DevRestartBanner.tsx b/ui/src/components/DevRestartBanner.tsx index 2ff666d9..fec523be 100644 --- a/ui/src/components/DevRestartBanner.tsx +++ b/ui/src/components/DevRestartBanner.tsx @@ -1,5 +1,8 @@ +import { useEffect, useState } from "react"; import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react"; -import type { DevServerHealthStatus } from "../api/health"; +import { healthApi, type DevServerHealthStatus } from "../api/health"; + +const RESTART_PENDING_RESET_MS = 30_000; function formatRelativeTimestamp(value: string | null): string | null { if (!value) return null; @@ -27,10 +30,39 @@ function describeReason(devServer: DevServerHealthStatus): string { } export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) { + const [restartPending, setRestartPending] = useState(false); + useEffect(() => { + if (!restartPending) return; + const timeout = window.setTimeout(() => { + setRestartPending(false); + }, RESTART_PENDING_RESET_MS); + return () => window.clearTimeout(timeout); + }, [restartPending]); + if (!devServer?.enabled || !devServer.restartRequired) return null; + const currentDevServer = devServer; const changedAt = formatRelativeTimestamp(devServer.lastChangedAt); const sample = devServer.changedPathsSample.slice(0, 3); + const activeRunLabel = `${devServer.activeRunCount} live run${ + devServer.activeRunCount === 1 ? "" : "s" + }`; + + async function requestRestartNow() { + const warning = + currentDevServer.activeRunCount > 0 + ? `Restart Paperclip now? This may interrupt ${activeRunLabel}.` + : "Restart Paperclip now?"; + if (!window.confirm(warning)) return; + + setRestartPending(true); + try { + await healthApi.requestDevServerRestart(); + } catch (error) { + setRestartPending(false); + window.alert(error instanceof Error ? error.message : "Failed to request restart"); + } + } return (
@@ -65,11 +97,11 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
-
+
{devServer.waitingForIdle ? (
- Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish + Waiting for {activeRunLabel} to finish
) : devServer.autoRestartEnabled ? (
@@ -82,6 +114,17 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta Restart pnpm dev:once after the active work is safe to interrupt
)} +
diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 978dd949..d2d50514 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -171,6 +171,7 @@ export function IssueRow({ {showUnreadDot ? ( ) : ( @@ -1795,6 +1796,7 @@ export function IssuesList({ {hasChildren ? ( -