Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: (55 commits) fix(issue-documents): address greptile review Update packages/shared/src/validators/issue.ts feat(ui): add issue document copy and download actions fix(ui): unify new issue upload action feat(ui): stage issue files before create feat(ui): handle issue document edit conflicts fix(ui): refresh issue documents from live events feat(ui): deep link issue documents fix(ui): streamline issue document chrome fix(ui): collapse empty document and attachment states fix(ui): simplify document card body layout fix(issues): address document review comments feat(issues): add issue documents and inline editing docs: add agent evals framework plan fix(cli): quote env values with special characters Fix worktree seed source selection fix: address greptile follow-up docs: add paperclip skill tightening plan fix: isolate codex home in worktrees Add worktree UI branding ... # Conflicts: # packages/db/src/migrations/meta/0028_snapshot.json # packages/db/src/migrations/meta/_journal.json # packages/shared/src/index.ts # server/src/routes/issues.ts # ui/src/api/issues.ts # ui/src/components/NewIssueDialog.tsx # ui/src/pages/IssueDetail.tsx
This commit is contained in:
53
ui/src/lib/assignees.test.ts
Normal file
53
ui/src/lib/assignees.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
currentUserAssigneeOption,
|
||||
formatAssigneeUserLabel,
|
||||
parseAssigneeValue,
|
||||
} from "./assignees";
|
||||
|
||||
describe("assignee selection helpers", () => {
|
||||
it("encodes and parses agent assignees", () => {
|
||||
const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" });
|
||||
|
||||
expect(value).toBe("agent:agent-123");
|
||||
expect(parseAssigneeValue(value)).toEqual({
|
||||
assigneeAgentId: "agent-123",
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes and parses current-user assignees", () => {
|
||||
const [option] = currentUserAssigneeOption("local-board");
|
||||
|
||||
expect(option).toEqual({
|
||||
id: "user:local-board",
|
||||
label: "Me",
|
||||
searchText: "me board human local-board",
|
||||
});
|
||||
expect(parseAssigneeValue(option.id)).toEqual({
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty selection as no assignee", () => {
|
||||
expect(parseAssigneeValue("")).toEqual({
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps backward compatibility for raw agent ids in saved drafts", () => {
|
||||
expect(parseAssigneeValue("legacy-agent-id")).toEqual({
|
||||
assigneeAgentId: "legacy-agent-id",
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats current and board user labels consistently", () => {
|
||||
expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me");
|
||||
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
|
||||
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
|
||||
});
|
||||
});
|
||||
51
ui/src/lib/assignees.ts
Normal file
51
ui/src/lib/assignees.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface AssigneeSelection {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export interface AssigneeOption {
|
||||
id: string;
|
||||
label: string;
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
|
||||
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
|
||||
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseAssigneeValue(value: string): AssigneeSelection {
|
||||
if (!value) {
|
||||
return { assigneeAgentId: null, assigneeUserId: null };
|
||||
}
|
||||
if (value.startsWith("agent:")) {
|
||||
const assigneeAgentId = value.slice("agent:".length);
|
||||
return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null };
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
const assigneeUserId = value.slice("user:".length);
|
||||
return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null };
|
||||
}
|
||||
// Backward compatibility for older drafts/defaults that stored a raw agent id.
|
||||
return { assigneeAgentId: value, assigneeUserId: null };
|
||||
}
|
||||
|
||||
export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] {
|
||||
if (!currentUserId) return [];
|
||||
return [{
|
||||
id: assigneeValueFromSelection({ assigneeUserId: currentUserId }),
|
||||
label: "Me",
|
||||
searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`,
|
||||
}];
|
||||
}
|
||||
|
||||
export function formatAssigneeUserLabel(
|
||||
userId: string | null | undefined,
|
||||
currentUserId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!userId) return null;
|
||||
if (currentUserId && userId === currentUserId) return "Me";
|
||||
if (userId === "local-board") return "Board";
|
||||
return userId.slice(0, 5);
|
||||
}
|
||||
65
ui/src/lib/company-page-memory.ts
Normal file
65
ui/src/lib/company-page-memory.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
extractCompanyPrefixFromPath,
|
||||
normalizeCompanyPrefix,
|
||||
toCompanyRelativePath,
|
||||
} from "./company-routes";
|
||||
|
||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
||||
|
||||
export function isRememberableCompanyPath(path: string): boolean {
|
||||
const pathname = path.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return true;
|
||||
const [root] = segments;
|
||||
if (GLOBAL_SEGMENTS.has(root!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findCompanyByPrefix<T extends { id: string; issuePrefix: string }>(params: {
|
||||
companies: T[];
|
||||
companyPrefix: string;
|
||||
}): T | null {
|
||||
const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix);
|
||||
return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null;
|
||||
}
|
||||
|
||||
export function getRememberedPathOwnerCompanyId<T extends { id: string; issuePrefix: string }>(params: {
|
||||
companies: T[];
|
||||
pathname: string;
|
||||
fallbackCompanyId: string | null;
|
||||
}): string | null {
|
||||
const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname);
|
||||
if (!routeCompanyPrefix) {
|
||||
return params.fallbackCompanyId;
|
||||
}
|
||||
|
||||
return findCompanyByPrefix({
|
||||
companies: params.companies,
|
||||
companyPrefix: routeCompanyPrefix,
|
||||
})?.id ?? null;
|
||||
}
|
||||
|
||||
export function sanitizeRememberedPathForCompany(params: {
|
||||
path: string | null | undefined;
|
||||
companyPrefix: string;
|
||||
}): string {
|
||||
const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard";
|
||||
if (!isRememberableCompanyPath(relativePath)) {
|
||||
return "/dashboard";
|
||||
}
|
||||
|
||||
const pathname = relativePath.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const [root, entityId] = segments;
|
||||
if (root === "issues" && entityId) {
|
||||
const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId);
|
||||
if (
|
||||
identifierMatch &&
|
||||
normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix)
|
||||
) {
|
||||
return "/dashboard";
|
||||
}
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
34
ui/src/lib/company-selection.test.ts
Normal file
34
ui/src/lib/company-selection.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldSyncCompanySelectionFromRoute } from "./company-selection";
|
||||
|
||||
describe("shouldSyncCompanySelectionFromRoute", () => {
|
||||
it("does not resync when selection already matches the route", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "route_sync",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "pap",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defers route sync while a manual company switch is in flight", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "manual",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "ret",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs back to the route company for non-manual mismatches", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "route_sync",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "ret",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
18
ui/src/lib/company-selection.ts
Normal file
18
ui/src/lib/company-selection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
|
||||
|
||||
export function shouldSyncCompanySelectionFromRoute(params: {
|
||||
selectionSource: CompanySelectionSource;
|
||||
selectedCompanyId: string | null;
|
||||
routeCompanyId: string;
|
||||
}): boolean {
|
||||
const { selectionSource, selectedCompanyId, routeCompanyId } = params;
|
||||
|
||||
if (selectedCompanyId === routeCompanyId) return false;
|
||||
|
||||
// Let manual company switches finish their remembered-path navigation first.
|
||||
if (selectionSource === "manual" && selectedCompanyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
|
||||
65
ui/src/lib/worktree-branding.ts
Normal file
65
ui/src/lib/worktree-branding.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export type WorktreeUiBranding = {
|
||||
enabled: true;
|
||||
name: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
function readMetaContent(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const element = document.querySelector(`meta[name="${name}"]`);
|
||||
const content = element?.getAttribute("content")?.trim();
|
||||
return content ? content : null;
|
||||
}
|
||||
|
||||
function normalizeHexColor(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const hex = value.startsWith("#") ? value.slice(1) : value;
|
||||
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
||||
return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`;
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
return `#${hex.toLowerCase()}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hexToRgb(color: string): { r: number; g: number; b: number } {
|
||||
const normalized = normalizeHexColor(color) ?? "#000000";
|
||||
return {
|
||||
r: Number.parseInt(normalized.slice(1, 3), 16),
|
||||
g: Number.parseInt(normalized.slice(3, 5), 16),
|
||||
b: Number.parseInt(normalized.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function relativeLuminanceChannel(value: number): number {
|
||||
const normalized = value / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function pickReadableTextColor(background: string): string {
|
||||
const { r, g, b } = hexToRgb(background);
|
||||
const luminance =
|
||||
(0.2126 * relativeLuminanceChannel(r)) +
|
||||
(0.7152 * relativeLuminanceChannel(g)) +
|
||||
(0.0722 * relativeLuminanceChannel(b));
|
||||
const whiteContrast = 1.05 / (luminance + 0.05);
|
||||
const blackContrast = (luminance + 0.05) / 0.05;
|
||||
return whiteContrast >= blackContrast ? "#f8fafc" : "#111827";
|
||||
}
|
||||
|
||||
export function getWorktreeUiBranding(): WorktreeUiBranding | null {
|
||||
if (readMetaContent("paperclip-worktree-enabled") !== "true") return null;
|
||||
|
||||
const name = readMetaContent("paperclip-worktree-name");
|
||||
const color = normalizeHexColor(readMetaContent("paperclip-worktree-color"));
|
||||
if (!name || !color) return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
name,
|
||||
color,
|
||||
textColor: normalizeHexColor(readMetaContent("paperclip-worktree-text-color")) ?? pickReadableTextColor(color),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user