[codex] UI and dev ops quality-of-life (#6384)
## Thinking Path > - Paperclip operators spend most of their time scanning the board, inbox, sidebar, and local dev status surfaces > - Small UI and dev-ops frictions make repeated operator workflows feel slower than they need to be > - The working branch contained several independent quality-of-life improvements mixed with larger cloud work > - Grouping these smaller UI/dev-ops changes together keeps review overhead reasonable without merging them into feature PRs > - This pull request collects the operator-facing QoL polish into one standalone branch > - The benefit is a cleaner board navigation and local dev recovery experience without depending on cloud upstream sync ## What Changed - Relaxed forced 44px touch targets for small inline widgets. - Fixed mobile mention menu scrolling and sidebar spacing on touch/mobile layouts. - Synced inbox hover state with j/k selection. - Moved plugin sidebar entries into the Work section. - Added manual dev-server restart action/banner behavior. - Logged plugin bridge 502 causes for better diagnosis. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm --filter @paperclipai/plugin-sdk build` - `pnpm exec vitest run ui/src/components/MarkdownEditor.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/SidebarProjects.test.tsx ui/src/pages/Inbox.test.tsx ui/src/components/DevRestartBanner.test.tsx server/src/__tests__/dev-server-status.test.ts server/src/__tests__/health-dev-server-token.test.ts server/src/__tests__/plugin-routes-authz.test.ts` initially failed only because plugin SDK `dist` was not built in the fresh worktree. - Rerun after build: `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` passed. - The remaining targeted UI/dev-server tests passed on the first post-install run. ## Visual Evidence - Sidebar layout and plugin Work section:  - Inbox/task row selection and hover-state surface:  - Dev restart banner desktop:  - Dev restart banner mobile:  ## Risks - Mostly UI/dev ergonomics with low data risk. - Sidebar and inbox changes touch frequently used navigation surfaces, so visual review on desktop/mobile is still useful. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use. Exact hosted model ID and context-window size are not exposed by the local Paperclip adapter runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 733 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
+17
-3
@@ -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<string, unknown>) {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1021,6 +1021,34 @@ export function pluginRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
function attachPluginBridgeErrorContext(
|
||||
req: Request,
|
||||
res: Response,
|
||||
err: unknown,
|
||||
bridgeError: PluginBridgeErrorResponse,
|
||||
metadata: Record<string, unknown>,
|
||||
): 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,4 +38,15 @@ export const healthApi = {
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
requestDevServerRestart: async (): Promise<void> => {
|
||||
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})`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<typeof createRoot> | 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(<DevRestartBanner devServer={devServer} />));
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
@@ -65,11 +97,11 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium md:justify-end">
|
||||
{devServer.waitingForIdle ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
|
||||
<TimerReset className="h-3.5 w-3.5" />
|
||||
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
|
||||
<span>Waiting for {activeRunLabel} to finish</span>
|
||||
</div>
|
||||
) : devServer.autoRestartEnabled ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
|
||||
@@ -82,6 +114,17 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
|
||||
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-amber-950 px-3 py-1.5 text-xs font-semibold text-amber-50 transition-colors hover:bg-amber-900 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
|
||||
onClick={() => {
|
||||
void requestRestartNow();
|
||||
}}
|
||||
disabled={restartPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>{restartPending ? "Restart requested" : "Restart now"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +171,7 @@ export function IssueRow({
|
||||
{showUnreadDot ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -200,6 +201,7 @@ export function IssueRow({
|
||||
) : onArchive ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -1706,6 +1706,7 @@ export function IssuesList({
|
||||
<button
|
||||
key={firstVisibleBlockerChip.blockerId}
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1781,7 +1782,7 @@ export function IssuesList({
|
||||
className={isMutedIssue ? "opacity-70" : undefined}
|
||||
mobileLeading={
|
||||
hasChildren ? (
|
||||
<button type="button" onClick={toggleCollapse}>
|
||||
<button type="button" data-slot="icon-button" onClick={toggleCollapse}>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
@@ -1795,6 +1796,7 @@ export function IssuesList({
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className="hidden shrink-0 items-center sm:inline-flex"
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
|
||||
@@ -686,7 +686,16 @@ describe("MarkdownEditor", () => {
|
||||
|
||||
async function openMentionMenuFor(
|
||||
handleChange: ReturnType<typeof vi.fn>,
|
||||
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot> }> {
|
||||
mentions = [
|
||||
{
|
||||
id: "project:project-123",
|
||||
kind: "project" as const,
|
||||
name: "Paperclip App",
|
||||
projectId: "project-123",
|
||||
projectColor: "#336699",
|
||||
},
|
||||
],
|
||||
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot>; menu: HTMLElement }> {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
@@ -694,15 +703,7 @@ describe("MarkdownEditor", () => {
|
||||
<MarkdownEditor
|
||||
value="@Pap"
|
||||
onChange={handleChange}
|
||||
mentions={[
|
||||
{
|
||||
id: "project:project-123",
|
||||
kind: "project",
|
||||
name: "Paperclip App",
|
||||
projectId: "project-123",
|
||||
projectColor: "#336699",
|
||||
},
|
||||
]}
|
||||
mentions={mentions}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -729,7 +730,9 @@ describe("MarkdownEditor", () => {
|
||||
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
||||
.find((node) => node.textContent?.includes("Paperclip App")) as HTMLButtonElement | undefined;
|
||||
expect(option).toBeTruthy();
|
||||
return { option: option!, root };
|
||||
const menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]') as HTMLElement | null;
|
||||
expect(menu).toBeTruthy();
|
||||
return { option: option!, root, menu: menu! };
|
||||
}
|
||||
|
||||
it("accepts mention selection from a touch tap", async () => {
|
||||
@@ -783,6 +786,99 @@ describe("MarkdownEditor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders all mention matches inside a bounded scroll container", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const mentions = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `project:project-${index}`,
|
||||
kind: "project" as const,
|
||||
name: `Paperclip App ${index}`,
|
||||
projectId: `project-${index}`,
|
||||
projectColor: "#336699",
|
||||
}));
|
||||
const { menu, root } = await openMentionMenuFor(handleChange, mentions);
|
||||
|
||||
const options = Array.from(menu.querySelectorAll('button[type="button"]'));
|
||||
expect(options).toHaveLength(12);
|
||||
expect(menu.className).toContain("max-h-[208px]");
|
||||
expect(menu.className).toContain("overflow-y-auto");
|
||||
expect(menu.style.touchAction).toBe("pan-y");
|
||||
|
||||
const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80 });
|
||||
act(() => {
|
||||
menu.dispatchEvent(wheel);
|
||||
});
|
||||
expect(wheel.defaultPrevented).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("caps rendered mention matches while keeping the menu scrollable", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const mentions = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: `project:project-${index}`,
|
||||
kind: "project" as const,
|
||||
name: `Paperclip App ${index}`,
|
||||
projectId: `project-${index}`,
|
||||
projectColor: "#336699",
|
||||
}));
|
||||
const { menu, root } = await openMentionMenuFor(handleChange, mentions);
|
||||
|
||||
const options = Array.from(menu.querySelectorAll('button[type="button"]'));
|
||||
expect(options).toHaveLength(50);
|
||||
expect(menu.className).toContain("overflow-y-auto");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("scrolls the active mention option into view during keyboard navigation", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
const mentions = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `project:project-${index}`,
|
||||
kind: "project" as const,
|
||||
name: `Paperclip App ${index}`,
|
||||
projectId: `project-${index}`,
|
||||
projectColor: "#336699",
|
||||
}));
|
||||
const { root } = await openMentionMenuFor(handleChange, mentions);
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
const editorScope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement;
|
||||
expect(editorScope).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
editorScope?.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
if (originalScrollIntoView) {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: originalScrollIntoView,
|
||||
});
|
||||
} else {
|
||||
delete (HTMLElement.prototype as unknown as { scrollIntoView?: unknown }).scrollIntoView;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not select when the touch moves like a scroll", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const { option, root } = await openMentionMenuFor(handleChange);
|
||||
|
||||
@@ -212,6 +212,7 @@ const MENTION_MENU_HEIGHT = 208;
|
||||
const MENTION_MENU_PADDING = 8;
|
||||
const MENTION_MENU_ROW_HEIGHT = 34;
|
||||
const MENTION_MENU_CHROME_HEIGHT = 8;
|
||||
const MAX_AUTOCOMPLETE_OPTIONS = 50;
|
||||
/** Roughly one space-width of breathing room between the caret and the menu. */
|
||||
const MENTION_MENU_CARET_GAP = 10;
|
||||
|
||||
@@ -603,6 +604,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const autocompleteOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const skillEnterArmedRef = useRef(false);
|
||||
const autocompleteSelectionHandledRef = useRef(false);
|
||||
const mentionActive = mentionState !== null && (
|
||||
@@ -648,10 +650,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
if (!q) return true;
|
||||
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
|
||||
})
|
||||
.slice(0, 8);
|
||||
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
|
||||
}
|
||||
if (!mentions) return [];
|
||||
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||
return mentions
|
||||
.filter((m) => m.name.toLowerCase().includes(q))
|
||||
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
|
||||
}, [mentionState, mentions, slashCommands]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
@@ -896,6 +900,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
};
|
||||
}, [checkMention, mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mentionActive) return;
|
||||
autocompleteOptionRefs.current.length = filteredMentions.length;
|
||||
if (mentionIndex >= filteredMentions.length) {
|
||||
setMentionIndex(Math.max(0, filteredMentions.length - 1));
|
||||
return;
|
||||
}
|
||||
const activeOption = autocompleteOptionRefs.current[mentionIndex];
|
||||
if (!activeOption || typeof activeOption.scrollIntoView !== "function") return;
|
||||
activeOption.scrollIntoView({ block: "nearest" });
|
||||
}, [filteredMentions.length, mentionActive, mentionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mentionActive) return;
|
||||
autocompleteSelectionHandledRef.current = false;
|
||||
@@ -1242,6 +1258,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
createPortal(
|
||||
<div
|
||||
data-paperclip-floating-ui=""
|
||||
data-testid="mention-autocomplete-menu"
|
||||
className="pointer-events-auto fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[208px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={{
|
||||
top: mentionMenuPosition.top,
|
||||
@@ -1255,6 +1272,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
key={option.id}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
ref={(node) => {
|
||||
autocompleteOptionRefs.current[i] = node;
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||
i === mentionIndex && "bg-accent",
|
||||
|
||||
@@ -67,7 +67,9 @@ vi.mock("../hooks/useInboxBadge", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
PluginSlotOutlet: ({ slotTypes }: { slotTypes: string[] }) => (
|
||||
<div data-plugin-slot-types={slotTypes.join(",")}>Plugin slot outlet</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/launchers", () => ({
|
||||
@@ -162,6 +164,28 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders plugin sidebar slots in Work below Workspaces", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const sidebarSlot = [...container.querySelectorAll("nav [data-plugin-slot-types]")]
|
||||
.find((node) => node.getAttribute("data-plugin-slot-types") === "sidebar");
|
||||
expect(sidebarSlot?.textContent).toContain("Plugin slot outlet");
|
||||
const workSectionContainer = sidebarSlot?.parentElement?.parentElement;
|
||||
const workText = workSectionContainer?.textContent ?? "";
|
||||
expect(workText).toContain("Work");
|
||||
expect(workText).toContain("Workspaces");
|
||||
expect(workText.indexOf("Workspaces")).toBeLessThan(workText.indexOf("Plugin slot outlet"));
|
||||
|
||||
const primaryNavText = container.querySelector("nav > div:first-child")?.textContent ?? "";
|
||||
expect(primaryNavText).toContain("Inbox");
|
||||
expect(primaryNavText).not.toContain("Plugin slot outlet");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flash the Workspaces link while experimental settings are loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
const root = await renderSidebar();
|
||||
|
||||
@@ -71,12 +71,13 @@ export function Sidebar() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* New Issue button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
data-slot="icon-button"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
@@ -90,6 +91,15 @@ export function Sidebar() {
|
||||
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
||||
alert={inboxBadge.failedRuns > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
@@ -97,21 +107,12 @@ export function Sidebar() {
|
||||
itemClassName="text-[13px] font-medium"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<PluginLauncherOutlet
|
||||
placementZones={["sidebar"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-0.5"
|
||||
itemClassName="text-[13px] font-medium"
|
||||
/>
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
|
||||
@@ -118,7 +118,7 @@ function SidebarAgentItem({
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 text-[13px] font-medium transition-colors",
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function SidebarNavItem({
|
||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
"flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
|
||||
@@ -19,6 +19,8 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
const mockOpenNewProject = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockPersistOrder = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
|
||||
const mockPointerState = vi.hoisted(() => ({ fine: true }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
@@ -63,7 +65,7 @@ vi.mock("../context/DialogContext", () => ({
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
isMobile: mockSidebarState.isMobile,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
}),
|
||||
}));
|
||||
@@ -192,6 +194,23 @@ describe("SidebarProjects", () => {
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
localStorage.clear();
|
||||
mockSidebarState.isMobile = false;
|
||||
mockPointerState.fine = true;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("(hover: hover)") && query.includes("(pointer: fine)")
|
||||
? mockPointerState.fine
|
||||
: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
makeProject({
|
||||
id: "project-a",
|
||||
@@ -254,6 +273,25 @@ describe("SidebarProjects", () => {
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
|
||||
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses plain project rows for top mode on mobile", async () => {
|
||||
mockSidebarState.isMobile = true;
|
||||
|
||||
await renderSidebarProjects();
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses plain project rows for top mode on coarse pointer screens", async () => {
|
||||
mockPointerState.fine = false;
|
||||
|
||||
await renderSidebarProjects();
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the heading for section menu and the plus button for project creation", async () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
{ value: "recent", label: "Recent" },
|
||||
];
|
||||
const REORDER_POINTER_MEDIA = "(hover: hover) and (pointer: fine)";
|
||||
|
||||
type ProjectItemProps = {
|
||||
activeProjectRef: string | null;
|
||||
@@ -74,6 +75,26 @@ function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Pr
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function hasFineReorderPointer() {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
|
||||
return window.matchMedia(REORDER_POINTER_MEDIA).matches;
|
||||
}
|
||||
|
||||
function useFineReorderPointer() {
|
||||
const [matches, setMatches] = useState(hasFineReorderPointer);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia(REORDER_POINTER_MEDIA);
|
||||
const onChange = (event: MediaQueryListEvent) => setMatches(event.matches);
|
||||
setMatches(query.matches);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
activeProjectRef,
|
||||
companyId,
|
||||
@@ -99,7 +120,7 @@ function ProjectItem({
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
"flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
|
||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
@@ -167,6 +188,7 @@ export function SidebarProjects() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const fineReorderPointer = useFineReorderPointer();
|
||||
const location = useLocation();
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
@@ -209,6 +231,7 @@ export function SidebarProjects() {
|
||||
[orderedProjects, sortMode],
|
||||
);
|
||||
const isTopMode = sortMode === "top";
|
||||
const canReorderProjects = isTopMode && !isMobile && fineReorderPointer;
|
||||
|
||||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||
@@ -310,7 +333,7 @@ export function SidebarProjects() {
|
||||
onRadioValueChange: persistSortMode,
|
||||
}}
|
||||
>
|
||||
{isTopMode ? (
|
||||
{canReorderProjects ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
|
||||
@@ -95,6 +95,7 @@ function SidebarSectionHeader({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className={cn(
|
||||
"inline-flex min-w-0 max-w-full items-center rounded-md px-1 py-0.5 text-left outline-none transition-colors",
|
||||
"hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
@@ -150,12 +151,13 @@ function SidebarSectionHeader({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group/sidebar-section px-3 py-1.5">
|
||||
<div className="group/sidebar-section px-3 py-1.5 pointer-coarse:py-1">
|
||||
<div className="relative flex min-h-6 min-w-0 items-center gap-1">
|
||||
{collapsible ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className="absolute -left-4 flex h-5 w-5 items-center justify-center rounded-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
aria-label={collapsible.open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
>
|
||||
|
||||
+12
-1
@@ -183,7 +183,18 @@
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
[data-slot="toggle"] {
|
||||
/* Small inline widgets keep their design size on touch devices —
|
||||
forcing 44px here stretches checkboxes, chip menus, and icon buttons
|
||||
that live inside dense rows (sidebar headers, issue rows, filter
|
||||
popovers). The surrounding row provides the touch area. */
|
||||
[data-slot="toggle"],
|
||||
[data-slot="checkbox"],
|
||||
[data-slot="icon-button"],
|
||||
[data-size="xs"],
|
||||
[data-size="icon-xs"],
|
||||
[data-size="icon-sm"],
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@ vi.mock("@/lib/router", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect.
|
||||
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
@@ -289,6 +294,59 @@ describe("Inbox toolbar", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs hover with j/k selection on inbox rows", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
|
||||
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
|
||||
apiMocks.issuesList.mockResolvedValue([issueA, issueB]);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("[data-inbox-item]");
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const linkOf = (row: Element): HTMLAnchorElement | null =>
|
||||
row.querySelector("a[data-inbox-issue-link]");
|
||||
|
||||
// Nothing selected before hover — both rows show the hover-accent class.
|
||||
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
|
||||
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
|
||||
|
||||
await act(async () => {
|
||||
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
|
||||
// After hovering row 1, that row is "selected" — same visual state as j/k selection.
|
||||
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent");
|
||||
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
|
||||
|
||||
await act(async () => {
|
||||
rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Hovering a different row moves the selection to follow the mouse.
|
||||
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent");
|
||||
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FailedRunInboxRow", () => {
|
||||
|
||||
+12
-1
@@ -2326,6 +2326,7 @@ export function Inbox() {
|
||||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -2358,6 +2359,7 @@ export function Inbox() {
|
||||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -2438,6 +2440,9 @@ export function Inbox() {
|
||||
onClick={() => {
|
||||
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
|
||||
}}
|
||||
>
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
@@ -2474,6 +2479,7 @@ export function Inbox() {
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(navIdx)}
|
||||
onMouseEnter={() => setSelectedIndex(navIdx)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
@@ -2641,7 +2647,12 @@ export function Inbox() {
|
||||
key={`sel-issue:${child.id}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(childNavIdx)}
|
||||
onClick={() => {
|
||||
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
|
||||
}}
|
||||
>
|
||||
{canArchiveIssue ? (
|
||||
<SwipeToArchive
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DevRestartBanner } from "@/components/DevRestartBanner";
|
||||
import type { DevServerHealthStatus } from "@/api/health";
|
||||
|
||||
const restartRequired: DevServerHealthStatus = {
|
||||
enabled: true,
|
||||
restartRequired: true,
|
||||
reason: "backend_changes_and_pending_migrations",
|
||||
lastChangedAt: new Date(Date.now() - 7 * 60_000).toISOString(),
|
||||
changedPathCount: 4,
|
||||
changedPathsSample: [
|
||||
"server/src/routes/health.ts",
|
||||
"server/src/dev-runner.ts",
|
||||
"packages/shared/src/api.ts",
|
||||
],
|
||||
pendingMigrations: ["0042_dev_server_health.sql"],
|
||||
autoRestartEnabled: false,
|
||||
activeRunCount: 0,
|
||||
waitingForIdle: false,
|
||||
lastRestartAt: new Date(Date.now() - 45 * 60_000).toISOString(),
|
||||
};
|
||||
|
||||
const restartWaitingForIdle: DevServerHealthStatus = {
|
||||
...restartRequired,
|
||||
reason: "backend_changes",
|
||||
pendingMigrations: [],
|
||||
autoRestartEnabled: true,
|
||||
activeRunCount: 2,
|
||||
waitingForIdle: true,
|
||||
};
|
||||
|
||||
function DevOpsSurfacesStory() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<section className="overflow-hidden border border-border bg-background">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Dev server restart banner
|
||||
</div>
|
||||
</div>
|
||||
<DevRestartBanner devServer={restartRequired} />
|
||||
</section>
|
||||
|
||||
<section className="max-w-[390px] overflow-hidden border border-border bg-background">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Mobile waiting state
|
||||
</div>
|
||||
</div>
|
||||
<DevRestartBanner devServer={restartWaitingForIdle} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Dev Ops Surfaces",
|
||||
component: DevOpsSurfacesStory,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Shows local development recovery surfaces, including the restart-required banner and its manual restart action.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof DevOpsSurfacesStory>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const DevOpsSurfaces: Story = {};
|
||||
Reference in New Issue
Block a user