mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[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:
parent
43c5bb81b6
commit
f257530537
29 changed files with 870 additions and 45 deletions
BIN
docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png
Normal file
BIN
docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png
Normal file
BIN
docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/pr-screenshots/pr-6384/inbox-rows-desktop.png
Normal file
BIN
docs/pr-screenshots/pr-6384/inbox-rows-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 733 KiB |
BIN
docs/pr-screenshots/pr-6384/sidebar-desktop.png
Normal file
BIN
docs/pr-screenshots/pr-6384/sidebar-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
|
|
@ -36,6 +36,7 @@ const autoRestartPollIntervalMs = 2500;
|
||||||
const gracefulShutdownTimeoutMs = 10_000;
|
const gracefulShutdownTimeoutMs = 10_000;
|
||||||
const changedPathSampleLimit = 5;
|
const changedPathSampleLimit = 5;
|
||||||
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
|
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 devServerStatusToken = mode === "dev" ? randomUUID() : null;
|
||||||
const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token";
|
const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token";
|
||||||
|
|
||||||
|
|
@ -70,6 +71,7 @@ const ignoredDirectoryNames = new Set([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ignoredRelativePaths = new Set([
|
const ignoredRelativePaths = new Set([
|
||||||
|
".paperclip/dev-server-restart-request.json",
|
||||||
".paperclip/dev-server-status.json",
|
".paperclip/dev-server-status.json",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -348,6 +350,13 @@ function writeDevServerStatus() {
|
||||||
function clearDevServerStatus() {
|
function clearDevServerStatus() {
|
||||||
if (mode !== "dev") return;
|
if (mode !== "dev") return;
|
||||||
rmSync(devServerStatusFilePath, { force: true });
|
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>) {
|
async function updateDevServiceRecord(extra?: Record<string, unknown>) {
|
||||||
|
|
@ -633,7 +642,8 @@ async function startServerChild() {
|
||||||
|
|
||||||
async function maybeAutoRestartChild() {
|
async function maybeAutoRestartChild() {
|
||||||
if (mode !== "dev" || restartInFlight || !child) return;
|
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;
|
restartInFlight = true;
|
||||||
let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null;
|
let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null;
|
||||||
|
|
@ -645,11 +655,15 @@ async function maybeAutoRestartChild() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const devServer = health?.devServer;
|
const devServer = health?.devServer;
|
||||||
if (!devServer?.enabled || devServer.autoRestartEnabled !== true) {
|
if (!devServer?.enabled) {
|
||||||
restartInFlight = false;
|
restartInFlight = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((devServer.activeRunCount ?? 0) > 0) {
|
if (!manualRestartRequested && devServer.autoRestartEnabled !== true) {
|
||||||
|
restartInFlight = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!manualRestartRequested && (devServer.activeRunCount ?? 0) > 0) {
|
||||||
restartInFlight = false;
|
restartInFlight = false;
|
||||||
return;
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
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 = [];
|
const tempDirs = [];
|
||||||
|
|
||||||
|
|
@ -73,4 +78,26 @@ describe("dev server status helpers", () => {
|
||||||
|
|
||||||
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull();
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import express from "express";
|
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;
|
jobDeps?: unknown;
|
||||||
toolDeps?: unknown;
|
toolDeps?: unknown;
|
||||||
bridgeDeps?: unknown;
|
bridgeDeps?: unknown;
|
||||||
|
captureJsonContext?: (context: unknown, body: unknown) => void;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
||||||
|
|
@ -56,6 +57,16 @@ async function createApp(
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
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) => {
|
app.use((req, _res, next) => {
|
||||||
req.actor = actor as typeof req.actor;
|
req.actor = actor as typeof req.actor;
|
||||||
next();
|
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 () => {
|
it("rejects manual job triggers for non-admin board users", async () => {
|
||||||
const scheduler = { triggerJob: vi.fn() };
|
const scheduler = { triggerJob: vi.fn() };
|
||||||
const jobStore = { getJobByIdForPlugin: 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;
|
const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
|
@ -25,6 +26,31 @@ export type DevServerHealthStatus = {
|
||||||
lastRestartAt: string | null;
|
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[] {
|
function normalizeStringArray(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db";
|
||||||
import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
|
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
|
||||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
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 { logger } from "../middleware/logger.js";
|
||||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||||
import { serverVersion } from "../version.js";
|
import { serverVersion } from "../version.js";
|
||||||
|
|
@ -44,6 +44,40 @@ export function healthRoutes(
|
||||||
) {
|
) {
|
||||||
const router = Router();
|
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) => {
|
router.get("/", async (req, res) => {
|
||||||
const actorType = "actor" in req ? req.actor?.type : null;
|
const actorType = "actor" in req ? req.actor?.type : null;
|
||||||
const exposeFullDetails = shouldExposeFullHealthDetails(
|
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
|
* POST /api/plugins/:pluginId/bridge/data
|
||||||
*
|
*
|
||||||
|
|
@ -1072,6 +1100,11 @@ export function pluginRoutes(
|
||||||
code: "WORKER_UNAVAILABLE",
|
code: "WORKER_UNAVAILABLE",
|
||||||
message: `Plugin is not ready (current status: ${plugin.status})`,
|
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);
|
res.status(502).json(bridgeError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1098,6 +1131,12 @@ export function pluginRoutes(
|
||||||
res.json({ data: result });
|
res.json({ data: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const bridgeError = mapRpcErrorToBridgeError(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);
|
res.status(502).json(bridgeError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1153,6 +1192,11 @@ export function pluginRoutes(
|
||||||
code: "WORKER_UNAVAILABLE",
|
code: "WORKER_UNAVAILABLE",
|
||||||
message: `Plugin is not ready (current status: ${plugin.status})`,
|
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);
|
res.status(502).json(bridgeError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1179,6 +1223,12 @@ export function pluginRoutes(
|
||||||
res.json({ data: result });
|
res.json({ data: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const bridgeError = mapRpcErrorToBridgeError(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);
|
res.status(502).json(bridgeError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1235,6 +1285,12 @@ export function pluginRoutes(
|
||||||
code: "WORKER_UNAVAILABLE",
|
code: "WORKER_UNAVAILABLE",
|
||||||
message: `Plugin is not ready (current status: ${plugin.status})`,
|
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);
|
res.status(502).json(bridgeError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1260,6 +1316,12 @@ export function pluginRoutes(
|
||||||
res.json({ data: result });
|
res.json({ data: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const bridgeError = mapRpcErrorToBridgeError(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);
|
res.status(502).json(bridgeError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1312,6 +1374,12 @@ export function pluginRoutes(
|
||||||
code: "WORKER_UNAVAILABLE",
|
code: "WORKER_UNAVAILABLE",
|
||||||
message: `Plugin is not ready (current status: ${plugin.status})`,
|
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);
|
res.status(502).json(bridgeError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1337,6 +1405,12 @@ export function pluginRoutes(
|
||||||
res.json({ data: result });
|
res.json({ data: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const bridgeError = mapRpcErrorToBridgeError(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);
|
res.status(502).json(bridgeError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,15 @@ export const healthApi = {
|
||||||
}
|
}
|
||||||
return res.json();
|
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})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
113
ui/src/components/DevRestartBanner.test.tsx
Normal file
113
ui/src/components/DevRestartBanner.test.tsx
Normal file
|
|
@ -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 { 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 {
|
function formatRelativeTimestamp(value: string | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
@ -27,10 +30,39 @@ function describeReason(devServer: DevServerHealthStatus): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
|
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;
|
if (!devServer?.enabled || !devServer.restartRequired) return null;
|
||||||
|
|
||||||
|
const currentDevServer = devServer;
|
||||||
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
|
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
|
||||||
const sample = devServer.changedPathsSample.slice(0, 3);
|
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 (
|
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">
|
<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>
|
</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 ? (
|
{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">
|
<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" />
|
<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>
|
</div>
|
||||||
) : devServer.autoRestartEnabled ? (
|
) : 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">
|
<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>
|
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ export function IssueRow({
|
||||||
{showUnreadDot ? (
|
{showUnreadDot ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
@ -200,6 +201,7 @@ export function IssueRow({
|
||||||
) : onArchive ? (
|
) : onArchive ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -1706,6 +1706,7 @@ export function IssuesList({
|
||||||
<button
|
<button
|
||||||
key={firstVisibleBlockerChip.blockerId}
|
key={firstVisibleBlockerChip.blockerId}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
@ -1781,7 +1782,7 @@ export function IssuesList({
|
||||||
className={isMutedIssue ? "opacity-70" : undefined}
|
className={isMutedIssue ? "opacity-70" : undefined}
|
||||||
mobileLeading={
|
mobileLeading={
|
||||||
hasChildren ? (
|
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")} />
|
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1795,6 +1796,7 @@ export function IssuesList({
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="icon-button"
|
||||||
className="hidden shrink-0 items-center sm:inline-flex"
|
className="hidden shrink-0 items-center sm:inline-flex"
|
||||||
onClick={toggleCollapse}
|
onClick={toggleCollapse}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -686,7 +686,16 @@ describe("MarkdownEditor", () => {
|
||||||
|
|
||||||
async function openMentionMenuFor(
|
async function openMentionMenuFor(
|
||||||
handleChange: ReturnType<typeof vi.fn>,
|
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);
|
const root = createRoot(container);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -694,15 +703,7 @@ describe("MarkdownEditor", () => {
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
value="@Pap"
|
value="@Pap"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
mentions={[
|
mentions={mentions}
|
||||||
{
|
|
||||||
id: "project:project-123",
|
|
||||||
kind: "project",
|
|
||||||
name: "Paperclip App",
|
|
||||||
projectId: "project-123",
|
|
||||||
projectColor: "#336699",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -729,7 +730,9 @@ describe("MarkdownEditor", () => {
|
||||||
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
||||||
.find((node) => node.textContent?.includes("Paperclip App")) as HTMLButtonElement | undefined;
|
.find((node) => node.textContent?.includes("Paperclip App")) as HTMLButtonElement | undefined;
|
||||||
expect(option).toBeTruthy();
|
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 () => {
|
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 () => {
|
it("does not select when the touch moves like a scroll", async () => {
|
||||||
const handleChange = vi.fn();
|
const handleChange = vi.fn();
|
||||||
const { option, root } = await openMentionMenuFor(handleChange);
|
const { option, root } = await openMentionMenuFor(handleChange);
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,7 @@ const MENTION_MENU_HEIGHT = 208;
|
||||||
const MENTION_MENU_PADDING = 8;
|
const MENTION_MENU_PADDING = 8;
|
||||||
const MENTION_MENU_ROW_HEIGHT = 34;
|
const MENTION_MENU_ROW_HEIGHT = 34;
|
||||||
const MENTION_MENU_CHROME_HEIGHT = 8;
|
const MENTION_MENU_CHROME_HEIGHT = 8;
|
||||||
|
const MAX_AUTOCOMPLETE_OPTIONS = 50;
|
||||||
/** Roughly one space-width of breathing room between the caret and the menu. */
|
/** Roughly one space-width of breathing room between the caret and the menu. */
|
||||||
const MENTION_MENU_CARET_GAP = 10;
|
const MENTION_MENU_CARET_GAP = 10;
|
||||||
|
|
||||||
|
|
@ -603,6 +604,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||||
const mentionStateRef = useRef<MentionState | null>(null);
|
const mentionStateRef = useRef<MentionState | null>(null);
|
||||||
const [mentionIndex, setMentionIndex] = useState(0);
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
|
const autocompleteOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
const skillEnterArmedRef = useRef(false);
|
const skillEnterArmedRef = useRef(false);
|
||||||
const autocompleteSelectionHandledRef = useRef(false);
|
const autocompleteSelectionHandledRef = useRef(false);
|
||||||
const mentionActive = mentionState !== null && (
|
const mentionActive = mentionState !== null && (
|
||||||
|
|
@ -648,10 +650,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
|
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
|
||||||
})
|
})
|
||||||
.slice(0, 8);
|
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
|
||||||
}
|
}
|
||||||
if (!mentions) return [];
|
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]);
|
}, [mentionState, mentions, slashCommands]);
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
|
|
@ -896,6 +900,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
};
|
};
|
||||||
}, [checkMention, mentionActive]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (mentionActive) return;
|
if (mentionActive) return;
|
||||||
autocompleteSelectionHandledRef.current = false;
|
autocompleteSelectionHandledRef.current = false;
|
||||||
|
|
@ -1242,6 +1258,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
data-paperclip-floating-ui=""
|
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"
|
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={{
|
style={{
|
||||||
top: mentionMenuPosition.top,
|
top: mentionMenuPosition.top,
|
||||||
|
|
@ -1255,6 +1272,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
key={option.id}
|
key={option.id}
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
ref={(node) => {
|
||||||
|
autocompleteOptionRefs.current[i] = node;
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
"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",
|
i === mentionIndex && "bg-accent",
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ vi.mock("../hooks/useInboxBadge", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/plugins/slots", () => ({
|
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", () => ({
|
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 () => {
|
it("does not flash the Workspaces link while experimental settings are loading", async () => {
|
||||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||||
const root = await renderSidebar();
|
const root = await renderSidebar();
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,13 @@ export function Sidebar() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-0.5">
|
||||||
{/* New Issue button aligned with nav items */}
|
{/* New Issue button aligned with nav items */}
|
||||||
<button
|
<button
|
||||||
onClick={() => openNewIssue()}
|
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" />
|
<SquarePen className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">New Issue</span>
|
<span className="truncate">New Issue</span>
|
||||||
|
|
@ -90,6 +91,15 @@ export function Sidebar() {
|
||||||
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
||||||
alert={inboxBadge.failedRuns > 0}
|
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
|
<PluginSlotOutlet
|
||||||
slotTypes={["sidebar"]}
|
slotTypes={["sidebar"]}
|
||||||
context={pluginContext}
|
context={pluginContext}
|
||||||
|
|
@ -97,21 +107,12 @@ export function Sidebar() {
|
||||||
itemClassName="text-[13px] font-medium"
|
itemClassName="text-[13px] font-medium"
|
||||||
missingBehavior="placeholder"
|
missingBehavior="placeholder"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<SidebarSection label="Work">
|
|
||||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
|
||||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
|
||||||
<PluginLauncherOutlet
|
<PluginLauncherOutlet
|
||||||
placementZones={["sidebar"]}
|
placementZones={["sidebar"]}
|
||||||
context={pluginContext}
|
context={pluginContext}
|
||||||
className="flex flex-col gap-0.5"
|
className="flex flex-col gap-0.5"
|
||||||
itemClassName="text-[13px] font-medium"
|
itemClassName="text-[13px] font-medium"
|
||||||
/>
|
/>
|
||||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
|
||||||
{showWorkspacesLink ? (
|
|
||||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
|
||||||
) : null}
|
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
<SidebarProjects />
|
<SidebarProjects />
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ function SidebarAgentItem({
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "bg-accent text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function SidebarNavItem({
|
||||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
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
|
isActive
|
||||||
? "bg-accent text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-foreground/80 hover:bg-accent/50 hover: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 mockOpenNewProject = vi.hoisted(() => vi.fn());
|
||||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||||
const mockPersistOrder = 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", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||||
|
|
@ -63,7 +65,7 @@ vi.mock("../context/DialogContext", () => ({
|
||||||
|
|
||||||
vi.mock("../context/SidebarContext", () => ({
|
vi.mock("../context/SidebarContext", () => ({
|
||||||
useSidebar: () => ({
|
useSidebar: () => ({
|
||||||
isMobile: false,
|
isMobile: mockSidebarState.isMobile,
|
||||||
setSidebarOpen: mockSetSidebarOpen,
|
setSidebarOpen: mockSetSidebarOpen,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
@ -192,6 +194,23 @@ describe("SidebarProjects", () => {
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
});
|
});
|
||||||
localStorage.clear();
|
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([
|
mockProjectsApi.list.mockResolvedValue([
|
||||||
makeProject({
|
makeProject({
|
||||||
id: "project-a",
|
id: "project-a",
|
||||||
|
|
@ -254,6 +273,25 @@ describe("SidebarProjects", () => {
|
||||||
|
|
||||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||||
expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
|
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 () => {
|
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: "alphabetical", label: "Alphabetical" },
|
||||||
{ value: "recent", label: "Recent" },
|
{ value: "recent", label: "Recent" },
|
||||||
];
|
];
|
||||||
|
const REORDER_POINTER_MEDIA = "(hover: hover) and (pointer: fine)";
|
||||||
|
|
||||||
type ProjectItemProps = {
|
type ProjectItemProps = {
|
||||||
activeProjectRef: string | null;
|
activeProjectRef: string | null;
|
||||||
|
|
@ -74,6 +75,26 @@ function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Pr
|
||||||
return sorted;
|
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({
|
function ProjectItem({
|
||||||
activeProjectRef,
|
activeProjectRef,
|
||||||
companyId,
|
companyId,
|
||||||
|
|
@ -99,7 +120,7 @@ function ProjectItem({
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||||
? "bg-accent text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||||
|
|
@ -167,6 +188,7 @@ export function SidebarProjects() {
|
||||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
const { openNewProject } = useDialogActions();
|
const { openNewProject } = useDialogActions();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
|
const fineReorderPointer = useFineReorderPointer();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const { data: projects } = useQuery({
|
const { data: projects } = useQuery({
|
||||||
|
|
@ -209,6 +231,7 @@ export function SidebarProjects() {
|
||||||
[orderedProjects, sortMode],
|
[orderedProjects, sortMode],
|
||||||
);
|
);
|
||||||
const isTopMode = sortMode === "top";
|
const isTopMode = sortMode === "top";
|
||||||
|
const canReorderProjects = isTopMode && !isMobile && fineReorderPointer;
|
||||||
|
|
||||||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||||
|
|
@ -310,7 +333,7 @@ export function SidebarProjects() {
|
||||||
onRadioValueChange: persistSortMode,
|
onRadioValueChange: persistSortMode,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isTopMode ? (
|
{canReorderProjects ? (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ function SidebarSectionHeader({
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="icon-button"
|
||||||
className={cn(
|
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",
|
"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",
|
"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 (
|
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">
|
<div className="relative flex min-h-6 min-w-0 items-center gap-1">
|
||||||
{collapsible ? (
|
{collapsible ? (
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}`}
|
aria-label={collapsible.open ? `Collapse ${label}` : `Expand ${label}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,18 @@
|
||||||
min-height: 44px;
|
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;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,11 @@ vi.mock("@/lib/router", () => ({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
(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 {
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
return {
|
return {
|
||||||
id: "issue-1",
|
id: "issue-1",
|
||||||
|
|
@ -289,6 +294,59 @@ describe("Inbox toolbar", () => {
|
||||||
root.unmount();
|
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", () => {
|
describe("FailedRunInboxRow", () => {
|
||||||
|
|
|
||||||
|
|
@ -2326,6 +2326,7 @@ export function Inbox() {
|
||||||
depth === 0 && hasChildren && collapseParentId ? (
|
depth === 0 && hasChildren && collapseParentId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="icon-button"
|
||||||
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
|
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -2358,6 +2359,7 @@ export function Inbox() {
|
||||||
depth === 0 && hasChildren && collapseParentId ? (
|
depth === 0 && hasChildren && collapseParentId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
@ -2438,6 +2440,9 @@ export function Inbox() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
|
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<IssueGroupHeader
|
<IssueGroupHeader
|
||||||
label={group.label}
|
label={group.label}
|
||||||
|
|
@ -2474,6 +2479,7 @@ export function Inbox() {
|
||||||
data-inbox-item
|
data-inbox-item
|
||||||
className="relative"
|
className="relative"
|
||||||
onClick={() => setSelectedIndex(navIdx)}
|
onClick={() => setSelectedIndex(navIdx)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(navIdx)}
|
||||||
>
|
>
|
||||||
{child}
|
{child}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2641,7 +2647,12 @@ export function Inbox() {
|
||||||
key={`sel-issue:${child.id}`}
|
key={`sel-issue:${child.id}`}
|
||||||
data-inbox-item
|
data-inbox-item
|
||||||
className="relative"
|
className="relative"
|
||||||
onClick={() => setSelectedIndex(childNavIdx)}
|
onClick={() => {
|
||||||
|
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{canArchiveIssue ? (
|
{canArchiveIssue ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
|
|
|
||||||
73
ui/storybook/stories/dev-ops-surfaces.stories.tsx
Normal file
73
ui/storybook/stories/dev-ops-surfaces.stories.tsx
Normal file
|
|
@ -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 = {};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue