Merge pull request #545 from paperclipai/feat/worktree-and-routing-polish
Add worktree:make and routing polish
This commit is contained in:
@@ -3,7 +3,14 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
|
import {
|
||||||
|
copyGitHooksToWorktreeGitDir,
|
||||||
|
copySeededSecretsKey,
|
||||||
|
rebindWorkspaceCwd,
|
||||||
|
resolveGitWorktreeAddArgs,
|
||||||
|
resolveWorktreeMakeTargetPath,
|
||||||
|
worktreeMakeCommand,
|
||||||
|
} from "../commands/worktree.js";
|
||||||
import {
|
import {
|
||||||
buildWorktreeConfig,
|
buildWorktreeConfig,
|
||||||
buildWorktreeEnvEntries,
|
buildWorktreeEnvEntries,
|
||||||
@@ -78,6 +85,36 @@ describe("worktree helpers", () => {
|
|||||||
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves worktree:make target paths under the user home directory", () => {
|
||||||
|
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
|
||||||
|
path.resolve(os.homedir(), "paperclip-pr-432"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects worktree:make names that are not safe directory/branch names", () => {
|
||||||
|
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
|
||||||
|
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds git worktree add args for new and existing branches", () => {
|
||||||
|
expect(
|
||||||
|
resolveGitWorktreeAddArgs({
|
||||||
|
branchName: "feature-branch",
|
||||||
|
targetPath: "/tmp/feature-branch",
|
||||||
|
branchExists: false,
|
||||||
|
}),
|
||||||
|
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveGitWorktreeAddArgs({
|
||||||
|
branchName: "feature-branch",
|
||||||
|
targetPath: "/tmp/feature-branch",
|
||||||
|
branchExists: true,
|
||||||
|
}),
|
||||||
|
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rewrites loopback auth URLs to the new port only", () => {
|
it("rewrites loopback auth URLs to the new port only", () => {
|
||||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||||
@@ -249,4 +286,44 @@ describe("worktree helpers", () => {
|
|||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
||||||
|
const repoRoot = path.join(tempRoot, "repo");
|
||||||
|
const fakeHome = path.join(tempRoot, "home");
|
||||||
|
const worktreePath = path.join(fakeHome, "paperclip-make-test");
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
const originalHome = process.env.HOME;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(repoRoot, { recursive: true });
|
||||||
|
fs.mkdirSync(fakeHome, { recursive: true });
|
||||||
|
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||||
|
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||||
|
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||||
|
|
||||||
|
process.env.HOME = fakeHome;
|
||||||
|
process.chdir(repoRoot);
|
||||||
|
|
||||||
|
await worktreeMakeCommand("paperclip-make-test", {
|
||||||
|
seed: false,
|
||||||
|
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
if (originalHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
}
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ type WorktreeInitOptions = {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorktreeMakeOptions = WorktreeInitOptions;
|
||||||
|
|
||||||
type WorktreeEnvOptions = {
|
type WorktreeEnvOptions = {
|
||||||
config?: string;
|
config?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -115,6 +117,62 @@ function nonEmpty(value: string | null | undefined): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWorktreeMakeName(name: string): string {
|
||||||
|
const value = nonEmpty(name);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Worktree name is required.");
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
|
||||||
|
throw new Error(
|
||||||
|
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorktreeMakeTargetPath(name: string): string {
|
||||||
|
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExecSyncErrorMessage(error: unknown): string | null {
|
||||||
|
if (!error || typeof error !== "object") {
|
||||||
|
return error instanceof Error ? error.message : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stderr = "stderr" in error ? error.stderr : null;
|
||||||
|
if (typeof stderr === "string") {
|
||||||
|
return nonEmpty(stderr);
|
||||||
|
}
|
||||||
|
if (stderr instanceof Buffer) {
|
||||||
|
return nonEmpty(stderr.toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? nonEmpty(error.message) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localBranchExists(cwd: string, branchName: string): boolean {
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||||
|
cwd,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGitWorktreeAddArgs(input: {
|
||||||
|
branchName: string;
|
||||||
|
targetPath: string;
|
||||||
|
branchExists: boolean;
|
||||||
|
}): string[] {
|
||||||
|
if (input.branchExists) {
|
||||||
|
return ["worktree", "add", input.targetPath, input.branchName];
|
||||||
|
}
|
||||||
|
return ["worktree", "add", "-b", input.branchName, input.targetPath, "HEAD"];
|
||||||
|
}
|
||||||
|
|
||||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||||
if (!existsSync(postmasterPidFile)) return null;
|
if (!existsSync(postmasterPidFile)) return null;
|
||||||
try {
|
try {
|
||||||
@@ -538,10 +596,7 @@ async function seedWorktreeDatabase(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||||
printPaperclipCliBanner();
|
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
|
||||||
|
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const name = resolveSuggestedWorktreeName(
|
const name = resolveSuggestedWorktreeName(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -642,6 +697,57 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||||
|
printPaperclipCliBanner();
|
||||||
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||||
|
await runWorktreeInit(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||||
|
printPaperclipCliBanner();
|
||||||
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
||||||
|
|
||||||
|
const name = resolveWorktreeMakeName(nameArg);
|
||||||
|
const sourceCwd = process.cwd();
|
||||||
|
const targetPath = resolveWorktreeMakeTargetPath(name);
|
||||||
|
if (existsSync(targetPath)) {
|
||||||
|
throw new Error(`Target path already exists: ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
const worktreeArgs = resolveGitWorktreeAddArgs({
|
||||||
|
branchName: name,
|
||||||
|
targetPath,
|
||||||
|
branchExists: localBranchExists(sourceCwd, name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const spinner = p.spinner();
|
||||||
|
spinner.start(`Creating git worktree at ${targetPath}...`);
|
||||||
|
try {
|
||||||
|
execFileSync("git", worktreeArgs, {
|
||||||
|
cwd: sourceCwd,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
spinner.stop(`Created git worktree at ${targetPath}.`);
|
||||||
|
} catch (error) {
|
||||||
|
spinner.stop(pc.red("Failed to create git worktree."));
|
||||||
|
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
try {
|
||||||
|
process.chdir(targetPath);
|
||||||
|
await runWorktreeInit({
|
||||||
|
...opts,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
|
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
const envPath = resolvePaperclipEnvFile(configPath);
|
const envPath = resolvePaperclipEnvFile(configPath);
|
||||||
@@ -665,6 +771,22 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void
|
|||||||
export function registerWorktreeCommands(program: Command): void {
|
export function registerWorktreeCommands(program: Command): void {
|
||||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("worktree:make")
|
||||||
|
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
|
||||||
|
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
|
||||||
|
.option("--instance <id>", "Explicit isolated instance id")
|
||||||
|
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||||
|
.option("--from-config <path>", "Source config.json to seed from")
|
||||||
|
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||||
|
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||||
|
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||||
|
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||||
|
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||||
|
.option("--no-seed", "Skip database seeding from the source instance")
|
||||||
|
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||||
|
.action(worktreeMakeCommand);
|
||||||
|
|
||||||
worktree
|
worktree
|
||||||
.command("init")
|
.command("init")
|
||||||
.description("Create repo-local config/env and an isolated instance for this worktree")
|
.description("Create repo-local config/env and an isolated instance for this worktree")
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
paperclipai worktree init
|
paperclipai worktree init
|
||||||
|
# or create the git worktree and initialize it in one step:
|
||||||
|
pnpm paperclipai worktree:make paperclip-pr-432
|
||||||
```
|
```
|
||||||
|
|
||||||
This command:
|
This command:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const logFile = path.join(logDir, "server.log");
|
|||||||
const sharedOpts = {
|
const sharedOpts = {
|
||||||
translateTime: "HH:MM:ss",
|
translateTime: "HH:MM:ss",
|
||||||
ignore: "pid,hostname",
|
ignore: "pid,hostname",
|
||||||
|
singleLine: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logger = pino({
|
export const logger = pino({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { NewAgent } from "./pages/NewAgent";
|
|||||||
import { AuthPage } from "./pages/Auth";
|
import { AuthPage } from "./pages/Auth";
|
||||||
import { BoardClaimPage } from "./pages/BoardClaim";
|
import { BoardClaimPage } from "./pages/BoardClaim";
|
||||||
import { InviteLandingPage } from "./pages/InviteLanding";
|
import { InviteLandingPage } from "./pages/InviteLanding";
|
||||||
|
import { NotFoundPage } from "./pages/NotFound";
|
||||||
import { queryKeys } from "./lib/queryKeys";
|
import { queryKeys } from "./lib/queryKeys";
|
||||||
import { useCompany } from "./context/CompanyContext";
|
import { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialog } from "./context/DialogContext";
|
||||||
@@ -141,6 +142,7 @@ function boardRoutes() {
|
|||||||
<Route path="inbox/new" element={<Inbox />} />
|
<Route path="inbox/new" element={<Inbox />} />
|
||||||
<Route path="inbox/all" element={<Inbox />} />
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
|
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -240,6 +242,7 @@ export function App() {
|
|||||||
<Route path=":companyPrefix" element={<Layout />}>
|
<Route path=":companyPrefix" element={<Layout />}>
|
||||||
{boardRoutes()}
|
{boardRoutes()}
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="*" element={<NotFoundPage scope="global" />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
<OnboardingWizard />
|
<OnboardingWizard />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { BookOpen, Moon, Sun } from "lucide-react";
|
import { BookOpen, Moon, Sun } from "lucide-react";
|
||||||
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
@@ -24,13 +24,20 @@ import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
|||||||
import { healthApi } from "../api/health";
|
import { healthApi } from "../api/health";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||||
const { openNewIssue, openOnboarding } = useDialog();
|
const { openNewIssue, openOnboarding } = useDialog();
|
||||||
const { togglePanelVisible } = usePanel();
|
const { togglePanelVisible } = usePanel();
|
||||||
const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const {
|
||||||
|
companies,
|
||||||
|
loading: companiesLoading,
|
||||||
|
selectedCompany,
|
||||||
|
selectedCompanyId,
|
||||||
|
setSelectedCompanyId,
|
||||||
|
} = useCompany();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -39,6 +46,13 @@ export function Layout() {
|
|||||||
const lastMainScrollTop = useRef(0);
|
const lastMainScrollTop = useRef(0);
|
||||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||||
|
const matchedCompany = useMemo(() => {
|
||||||
|
if (!companyPrefix) return null;
|
||||||
|
const requestedPrefix = companyPrefix.toUpperCase();
|
||||||
|
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
|
||||||
|
}, [companies, companyPrefix]);
|
||||||
|
const hasUnknownCompanyPrefix =
|
||||||
|
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
||||||
const { data: health } = useQuery({
|
const { data: health } = useQuery({
|
||||||
queryKey: queryKeys.health,
|
queryKey: queryKeys.health,
|
||||||
queryFn: () => healthApi.get(),
|
queryFn: () => healthApi.get(),
|
||||||
@@ -57,30 +71,30 @@ export function Layout() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!companyPrefix || companiesLoading || companies.length === 0) return;
|
if (!companyPrefix || companiesLoading || companies.length === 0) return;
|
||||||
|
|
||||||
const requestedPrefix = companyPrefix.toUpperCase();
|
if (!matchedCompany) {
|
||||||
const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix);
|
const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
|
||||||
|
?? companies[0]
|
||||||
if (!matched) {
|
?? null;
|
||||||
const fallback =
|
if (fallback && selectedCompanyId !== fallback.id) {
|
||||||
(selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
|
setSelectedCompanyId(fallback.id, { source: "route_sync" });
|
||||||
?? companies[0]!;
|
}
|
||||||
navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (companyPrefix !== matched.issuePrefix) {
|
if (companyPrefix !== matchedCompany.issuePrefix) {
|
||||||
const suffix = location.pathname.replace(/^\/[^/]+/, "");
|
const suffix = location.pathname.replace(/^\/[^/]+/, "");
|
||||||
navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true });
|
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCompanyId !== matched.id) {
|
if (selectedCompanyId !== matchedCompany.id) {
|
||||||
setSelectedCompanyId(matched.id, { source: "route_sync" });
|
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
companyPrefix,
|
companyPrefix,
|
||||||
companies,
|
companies,
|
||||||
companiesLoading,
|
companiesLoading,
|
||||||
|
matchedCompany,
|
||||||
location.pathname,
|
location.pathname,
|
||||||
location.search,
|
location.search,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -282,7 +296,14 @@ export function Layout() {
|
|||||||
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
|
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
|
||||||
onScroll={handleMainScroll}
|
onScroll={handleMainScroll}
|
||||||
>
|
>
|
||||||
<Outlet />
|
{hasUnknownCompanyPrefix ? (
|
||||||
|
<NotFoundPage
|
||||||
|
scope="invalid_company_prefix"
|
||||||
|
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Outlet />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
<PropertiesPanel />
|
<PropertiesPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -539,12 +539,6 @@ export function Inbox() {
|
|||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = touchedIssues.length > 0;
|
const hasTouchedIssues = touchedIssues.length > 0;
|
||||||
|
|
||||||
const newItemCount =
|
|
||||||
failedRuns.length +
|
|
||||||
staleIssues.length +
|
|
||||||
(showAggregateAgentError ? 1 : 0) +
|
|
||||||
(showBudgetAlert ? 1 : 0);
|
|
||||||
|
|
||||||
const showJoinRequestsCategory =
|
const showJoinRequestsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||||
const showTouchedCategory =
|
const showTouchedCategory =
|
||||||
@@ -595,16 +589,7 @@ export function Inbox() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
value: "new",
|
value: "new",
|
||||||
label: (
|
label: "New",
|
||||||
<>
|
|
||||||
New
|
|
||||||
{newItemCount > 0 && (
|
|
||||||
<span className="ml-1.5 rounded-full bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-medium text-blue-500">
|
|
||||||
{newItemCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
]}
|
]}
|
||||||
|
|||||||
66
ui/src/pages/NotFound.tsx
Normal file
66
ui/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Link, useLocation } from "@/lib/router";
|
||||||
|
import { AlertTriangle, Compass } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
|
||||||
|
type NotFoundScope = "board" | "invalid_company_prefix" | "global";
|
||||||
|
|
||||||
|
interface NotFoundPageProps {
|
||||||
|
scope?: NotFoundScope;
|
||||||
|
requestedPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotFoundPage({ scope = "global", requestedPrefix }: NotFoundPageProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const { companies, selectedCompany } = useCompany();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([{ label: "Not Found" }]);
|
||||||
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
const fallbackCompany = selectedCompany ?? companies[0] ?? null;
|
||||||
|
const dashboardHref = fallbackCompany ? `/${fallbackCompany.issuePrefix}/dashboard` : "/";
|
||||||
|
const currentPath = `${location.pathname}${location.search}${location.hash}`;
|
||||||
|
const normalizedPrefix = requestedPrefix?.toUpperCase();
|
||||||
|
|
||||||
|
const title = scope === "invalid_company_prefix" ? "Company not found" : "Page not found";
|
||||||
|
const description =
|
||||||
|
scope === "invalid_company_prefix"
|
||||||
|
? `No company matches prefix "${normalizedPrefix ?? "unknown"}".`
|
||||||
|
: "This route does not exist.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl py-10">
|
||||||
|
<div className="rounded-lg border border-border bg-card p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Requested path: <code className="font-mono">{currentPath}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={dashboardHref}>
|
||||||
|
<Compass className="mr-1.5 h-4 w-4" />
|
||||||
|
Open dashboard
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/">Go home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user