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:
Dotta
2026-03-14 12:24:40 -05:00
136 changed files with 17867 additions and 1511 deletions

View 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
View 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);
}

View 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;
}

View 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);
});
});

View 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;
}

View File

@@ -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,

View 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),
};
}