Merge remote-tracking branch 'public-gh/master' into pr-432
* public-gh/master: Add worktree UI branding Fix company switch remembered routes Add me and unassigned assignee options feat: skip pre-filled assignee/project fields when tabbing in new issue dialog Fix manual company switch route sync Delay onboarding starter task creation until launch
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
buildWorktreeConfig,
|
buildWorktreeConfig,
|
||||||
buildWorktreeEnvEntries,
|
buildWorktreeEnvEntries,
|
||||||
formatShellExports,
|
formatShellExports,
|
||||||
|
generateWorktreeColor,
|
||||||
resolveWorktreeSeedPlan,
|
resolveWorktreeSeedPlan,
|
||||||
resolveWorktreeLocalPaths,
|
resolveWorktreeLocalPaths,
|
||||||
rewriteLocalUrlPort,
|
rewriteLocalUrlPort,
|
||||||
@@ -181,13 +182,22 @@ describe("worktree helpers", () => {
|
|||||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const env = buildWorktreeEnvEntries(paths);
|
const env = buildWorktreeEnvEntries(paths, {
|
||||||
|
name: "feature-worktree-support",
|
||||||
|
color: "#3abf7a",
|
||||||
|
});
|
||||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||||
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||||
|
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
|
||||||
|
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
|
||||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("generates vivid worktree colors as hex", () => {
|
||||||
|
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
||||||
const minimal = resolveWorktreeSeedPlan("minimal");
|
const minimal = resolveWorktreeSeedPlan("minimal");
|
||||||
const full = resolveWorktreeSeedPlan("full");
|
const full = resolveWorktreeSeedPlan("full");
|
||||||
@@ -280,7 +290,10 @@ describe("worktree helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
||||||
expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
const envContents = fs.readFileSync(envPath, "utf8");
|
||||||
|
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||||
|
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
|
||||||
|
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=#[0-9a-f]{6}/);
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
if (originalJwtSecret === undefined) {
|
if (originalJwtSecret === undefined) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import { expandHomePrefix } from "../config/home.js";
|
import { expandHomePrefix } from "../config/home.js";
|
||||||
@@ -44,6 +45,11 @@ export type WorktreeLocalPaths = {
|
|||||||
storageDir: string;
|
storageDir: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorktreeUiBranding = {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
||||||
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string)
|
|||||||
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hslComponentToHex(n: number): string {
|
||||||
|
return Math.round(Math.max(0, Math.min(255, n)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToHex(hue: number, saturation: number, lightness: number): string {
|
||||||
|
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
||||||
|
const l = Math.max(0, Math.min(100, lightness)) / 100;
|
||||||
|
const c = (1 - Math.abs((2 * l) - 1)) * s;
|
||||||
|
const h = ((hue % 360) + 360) % 360;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - (c / 2);
|
||||||
|
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
|
||||||
|
if (h < 60) {
|
||||||
|
r = c;
|
||||||
|
g = x;
|
||||||
|
} else if (h < 120) {
|
||||||
|
r = x;
|
||||||
|
g = c;
|
||||||
|
} else if (h < 180) {
|
||||||
|
g = c;
|
||||||
|
b = x;
|
||||||
|
} else if (h < 240) {
|
||||||
|
g = x;
|
||||||
|
b = c;
|
||||||
|
} else if (h < 300) {
|
||||||
|
r = x;
|
||||||
|
b = c;
|
||||||
|
} else {
|
||||||
|
r = c;
|
||||||
|
b = x;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateWorktreeColor(): string {
|
||||||
|
return hslToHex(randomInt(0, 360), 68, 56);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveWorktreeLocalPaths(opts: {
|
export function resolveWorktreeLocalPaths(opts: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
@@ -196,13 +247,18 @@ export function buildWorktreeConfig(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
|
export function buildWorktreeEnvEntries(
|
||||||
|
paths: WorktreeLocalPaths,
|
||||||
|
branding?: WorktreeUiBranding,
|
||||||
|
): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
PAPERCLIP_HOME: paths.homeDir,
|
PAPERCLIP_HOME: paths.homeDir,
|
||||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||||
PAPERCLIP_CONFIG: paths.configPath,
|
PAPERCLIP_CONFIG: paths.configPath,
|
||||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||||
PAPERCLIP_IN_WORKTREE: "true",
|
PAPERCLIP_IN_WORKTREE: "true",
|
||||||
|
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
|
||||||
|
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
buildWorktreeEnvEntries,
|
buildWorktreeEnvEntries,
|
||||||
DEFAULT_WORKTREE_HOME,
|
DEFAULT_WORKTREE_HOME,
|
||||||
formatShellExports,
|
formatShellExports,
|
||||||
|
generateWorktreeColor,
|
||||||
isWorktreeSeedMode,
|
isWorktreeSeedMode,
|
||||||
resolveSuggestedWorktreeName,
|
resolveSuggestedWorktreeName,
|
||||||
resolveWorktreeSeedPlan,
|
resolveWorktreeSeedPlan,
|
||||||
@@ -623,7 +624,7 @@ async function seedWorktreeDatabase(input: {
|
|||||||
|
|
||||||
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const name = resolveSuggestedWorktreeName(
|
const worktreeName = resolveSuggestedWorktreeName(
|
||||||
cwd,
|
cwd,
|
||||||
opts.name ?? detectGitBranchName(cwd) ?? undefined,
|
opts.name ?? detectGitBranchName(cwd) ?? undefined,
|
||||||
);
|
);
|
||||||
@@ -631,12 +632,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||||||
if (!isWorktreeSeedMode(seedMode)) {
|
if (!isWorktreeSeedMode(seedMode)) {
|
||||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||||
}
|
}
|
||||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName);
|
||||||
const paths = resolveWorktreeLocalPaths({
|
const paths = resolveWorktreeLocalPaths({
|
||||||
cwd,
|
cwd,
|
||||||
homeDir: resolveWorktreeHome(opts.home),
|
homeDir: resolveWorktreeHome(opts.home),
|
||||||
instanceId,
|
instanceId,
|
||||||
});
|
});
|
||||||
|
const branding = {
|
||||||
|
name: worktreeName,
|
||||||
|
color: generateWorktreeColor(),
|
||||||
|
};
|
||||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||||
|
|
||||||
@@ -669,7 +674,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||||||
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
|
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
|
||||||
mergePaperclipEnvEntries(
|
mergePaperclipEnvEntries(
|
||||||
{
|
{
|
||||||
...buildWorktreeEnvEntries(paths),
|
...buildWorktreeEnvEntries(paths, branding),
|
||||||
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
|
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
|
||||||
},
|
},
|
||||||
paths.envPath,
|
paths.envPath,
|
||||||
@@ -710,6 +715,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||||||
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
|
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
|
||||||
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
|
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
|
||||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||||
|
p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`));
|
||||||
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
|
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
|
||||||
if (copiedGitHooks?.copied) {
|
if (copiedGitHooks?.copied) {
|
||||||
p.log.message(
|
p.log.message(
|
||||||
|
|||||||
@@ -152,7 +152,13 @@ Seed modes:
|
|||||||
|
|
||||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||||
|
|
||||||
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
|
That repo-local env also sets:
|
||||||
|
|
||||||
|
- `PAPERCLIP_IN_WORKTREE=true`
|
||||||
|
- `PAPERCLIP_WORKTREE_NAME=<worktree-name>`
|
||||||
|
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
|
||||||
|
|
||||||
|
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
|
||||||
|
|
||||||
Print shell exports explicitly when needed:
|
Print shell exports explicitly when needed:
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js";
|
import {
|
||||||
|
applyUiBranding,
|
||||||
|
getWorktreeUiBranding,
|
||||||
|
isWorktreeUiBrandingEnabled,
|
||||||
|
renderFaviconLinks,
|
||||||
|
renderRuntimeBrandingMeta,
|
||||||
|
} from "../ui-branding.js";
|
||||||
|
|
||||||
const TEMPLATE = `<!doctype html>
|
const TEMPLATE = `<!doctype html>
|
||||||
<head>
|
<head>
|
||||||
|
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
|
||||||
|
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
|
||||||
<!-- PAPERCLIP_FAVICON_START -->
|
<!-- PAPERCLIP_FAVICON_START -->
|
||||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
@@ -18,21 +26,57 @@ describe("ui branding", () => {
|
|||||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
|
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the worktree favicon asset set when enabled", () => {
|
it("resolves name, color, and text color for worktree branding", () => {
|
||||||
const links = renderFaviconLinks(true);
|
const branding = getWorktreeUiBranding({
|
||||||
expect(links).toContain("/worktree-favicon.ico");
|
PAPERCLIP_IN_WORKTREE: "true",
|
||||||
expect(links).toContain("/worktree-favicon.svg");
|
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||||
expect(links).toContain("/worktree-favicon-32x32.png");
|
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||||
expect(links).toContain("/worktree-favicon-16x16.png");
|
});
|
||||||
|
|
||||||
|
expect(branding.enabled).toBe(true);
|
||||||
|
expect(branding.name).toBe("paperclip-pr-432");
|
||||||
|
expect(branding.color).toBe("#4f86f7");
|
||||||
|
expect(branding.textColor).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
expect(branding.faviconHref).toContain("data:image/svg+xml,");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rewrites the favicon block for worktree instances only", () => {
|
it("renders a dynamic worktree favicon when enabled", () => {
|
||||||
const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" });
|
const links = renderFaviconLinks(
|
||||||
expect(branded).toContain("/worktree-favicon.svg");
|
getWorktreeUiBranding({
|
||||||
|
PAPERCLIP_IN_WORKTREE: "true",
|
||||||
|
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||||
|
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(links).toContain("data:image/svg+xml,");
|
||||||
|
expect(links).toContain('rel="shortcut icon"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders runtime branding metadata for the ui", () => {
|
||||||
|
const meta = renderRuntimeBrandingMeta(
|
||||||
|
getWorktreeUiBranding({
|
||||||
|
PAPERCLIP_IN_WORKTREE: "true",
|
||||||
|
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||||
|
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(meta).toContain('name="paperclip-worktree-name"');
|
||||||
|
expect(meta).toContain('content="paperclip-pr-432"');
|
||||||
|
expect(meta).toContain('name="paperclip-worktree-color"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites the favicon and runtime branding blocks for worktree instances only", () => {
|
||||||
|
const branded = applyUiBranding(TEMPLATE, {
|
||||||
|
PAPERCLIP_IN_WORKTREE: "true",
|
||||||
|
PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432",
|
||||||
|
PAPERCLIP_WORKTREE_COLOR: "#4f86f7",
|
||||||
|
});
|
||||||
|
expect(branded).toContain("data:image/svg+xml,");
|
||||||
|
expect(branded).toContain('name="paperclip-worktree-name"');
|
||||||
expect(branded).not.toContain('href="/favicon.svg"');
|
expect(branded).not.toContain('href="/favicon.svg"');
|
||||||
|
|
||||||
const defaultHtml = applyUiBranding(TEMPLATE, {});
|
const defaultHtml = applyUiBranding(TEMPLATE, {});
|
||||||
expect(defaultHtml).toContain('href="/favicon.svg"');
|
expect(defaultHtml).toContain('href="/favicon.svg"');
|
||||||
expect(defaultHtml).not.toContain("/worktree-favicon.svg");
|
expect(defaultHtml).not.toContain('name="paperclip-worktree-name"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const FAVICON_BLOCK_START = "<!-- PAPERCLIP_FAVICON_START -->";
|
const FAVICON_BLOCK_START = "<!-- PAPERCLIP_FAVICON_START -->";
|
||||||
const FAVICON_BLOCK_END = "<!-- PAPERCLIP_FAVICON_END -->";
|
const FAVICON_BLOCK_END = "<!-- PAPERCLIP_FAVICON_END -->";
|
||||||
|
const RUNTIME_BRANDING_BLOCK_START = "<!-- PAPERCLIP_RUNTIME_BRANDING_START -->";
|
||||||
|
const RUNTIME_BRANDING_BLOCK_END = "<!-- PAPERCLIP_RUNTIME_BRANDING_END -->";
|
||||||
|
|
||||||
const DEFAULT_FAVICON_LINKS = [
|
const DEFAULT_FAVICON_LINKS = [
|
||||||
'<link rel="icon" href="/favicon.ico" sizes="48x48" />',
|
'<link rel="icon" href="/favicon.ico" sizes="48x48" />',
|
||||||
@@ -8,12 +10,13 @@ const DEFAULT_FAVICON_LINKS = [
|
|||||||
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
|
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const WORKTREE_FAVICON_LINKS = [
|
export type WorktreeUiBranding = {
|
||||||
'<link rel="icon" href="/worktree-favicon.ico" sizes="48x48" />',
|
enabled: boolean;
|
||||||
'<link rel="icon" href="/worktree-favicon.svg" type="image/svg+xml" />',
|
name: string | null;
|
||||||
'<link rel="icon" type="image/png" sizes="32x32" href="/worktree-favicon-32x32.png" />',
|
color: string | null;
|
||||||
'<link rel="icon" type="image/png" sizes="16x16" href="/worktree-favicon-16x16.png" />',
|
textColor: string | null;
|
||||||
].join("\n");
|
faviconHref: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
@@ -21,21 +24,194 @@ function isTruthyEnvValue(value: string | undefined): boolean {
|
|||||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nonEmpty(value: string | undefined): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHexColor(value: string | undefined): string | null {
|
||||||
|
const raw = nonEmpty(value);
|
||||||
|
if (!raw) return null;
|
||||||
|
const hex = raw.startsWith("#") ? raw.slice(1) : raw;
|
||||||
|
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 hslComponentToHex(n: number): string {
|
||||||
|
return Math.round(Math.max(0, Math.min(255, n)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToHex(hue: number, saturation: number, lightness: number): string {
|
||||||
|
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
||||||
|
const l = Math.max(0, Math.min(100, lightness)) / 100;
|
||||||
|
const c = (1 - Math.abs((2 * l) - 1)) * s;
|
||||||
|
const h = ((hue % 360) + 360) % 360;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - (c / 2);
|
||||||
|
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
|
||||||
|
if (h < 60) {
|
||||||
|
r = c;
|
||||||
|
g = x;
|
||||||
|
} else if (h < 120) {
|
||||||
|
r = x;
|
||||||
|
g = c;
|
||||||
|
} else if (h < 180) {
|
||||||
|
g = c;
|
||||||
|
b = x;
|
||||||
|
} else if (h < 240) {
|
||||||
|
g = x;
|
||||||
|
b = c;
|
||||||
|
} else if (h < 300) {
|
||||||
|
r = x;
|
||||||
|
b = c;
|
||||||
|
} else {
|
||||||
|
r = c;
|
||||||
|
b = x;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveColorFromSeed(seed: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (const char of seed) {
|
||||||
|
hash = ((hash * 33) + char.charCodeAt(0)) >>> 0;
|
||||||
|
}
|
||||||
|
return hslToHex(hash % 360, 68, 56);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 relativeLuminance(color: string): number {
|
||||||
|
const { r, g, b } = hexToRgb(color);
|
||||||
|
return (
|
||||||
|
(0.2126 * relativeLuminanceChannel(r)) +
|
||||||
|
(0.7152 * relativeLuminanceChannel(g)) +
|
||||||
|
(0.0722 * relativeLuminanceChannel(b))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickReadableTextColor(background: string): string {
|
||||||
|
const backgroundLuminance = relativeLuminance(background);
|
||||||
|
const whiteContrast = 1.05 / (backgroundLuminance + 0.05);
|
||||||
|
const blackContrast = (backgroundLuminance + 0.05) / 0.05;
|
||||||
|
return whiteContrast >= blackContrast ? "#f8fafc" : "#111827";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFaviconDataUrl(background: string, foreground: string): string {
|
||||||
|
const svg = [
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">',
|
||||||
|
`<rect width="24" height="24" rx="6" fill="${background}"/>`,
|
||||||
|
`<path stroke="${foreground}" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.15" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>`,
|
||||||
|
"</svg>",
|
||||||
|
].join("");
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE);
|
return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderFaviconLinks(worktree: boolean): string {
|
export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): WorktreeUiBranding {
|
||||||
return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS;
|
if (!isWorktreeUiBrandingEnabled(env)) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
name: null,
|
||||||
|
color: null,
|
||||||
|
textColor: null,
|
||||||
|
faviconHref: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? "worktree";
|
||||||
|
const color = normalizeHexColor(env.PAPERCLIP_WORKTREE_COLOR) ?? deriveColorFromSeed(name);
|
||||||
|
const textColor = pickReadableTextColor(color);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
textColor,
|
||||||
|
faviconHref: createFaviconDataUrl(color, textColor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFaviconLinks(branding: WorktreeUiBranding): string {
|
||||||
|
if (!branding.enabled || !branding.faviconHref) return DEFAULT_FAVICON_LINKS;
|
||||||
|
|
||||||
|
const href = escapeHtmlAttribute(branding.faviconHref);
|
||||||
|
return [
|
||||||
|
`<link rel="icon" href="${href}" type="image/svg+xml" sizes="any" />`,
|
||||||
|
`<link rel="shortcut icon" href="${href}" type="image/svg+xml" />`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string {
|
||||||
|
if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return "";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<meta name="paperclip-worktree-enabled" content="true" />',
|
||||||
|
`<meta name="paperclip-worktree-name" content="${escapeHtmlAttribute(branding.name)}" />`,
|
||||||
|
`<meta name="paperclip-worktree-color" content="${escapeHtmlAttribute(branding.color)}" />`,
|
||||||
|
`<meta name="paperclip-worktree-text-color" content="${escapeHtmlAttribute(branding.textColor)}" />`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMarkedBlock(html: string, startMarker: string, endMarker: string, content: string): string {
|
||||||
|
const start = html.indexOf(startMarker);
|
||||||
|
const end = html.indexOf(endMarker);
|
||||||
|
if (start === -1 || end === -1 || end < start) return html;
|
||||||
|
|
||||||
|
const before = html.slice(0, start + startMarker.length);
|
||||||
|
const after = html.slice(end);
|
||||||
|
const indentedContent = content
|
||||||
|
? `\n${content
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => ` ${line}`)
|
||||||
|
.join("\n")}\n `
|
||||||
|
: "\n ";
|
||||||
|
return `${before}${indentedContent}${after}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string {
|
export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const start = html.indexOf(FAVICON_BLOCK_START);
|
const branding = getWorktreeUiBranding(env);
|
||||||
const end = html.indexOf(FAVICON_BLOCK_END);
|
const withFavicon = replaceMarkedBlock(html, FAVICON_BLOCK_START, FAVICON_BLOCK_END, renderFaviconLinks(branding));
|
||||||
if (start === -1 || end === -1 || end < start) return html;
|
return replaceMarkedBlock(
|
||||||
|
withFavicon,
|
||||||
const before = html.slice(0, start + FAVICON_BLOCK_START.length);
|
RUNTIME_BRANDING_BLOCK_START,
|
||||||
const after = html.slice(end);
|
RUNTIME_BRANDING_BLOCK_END,
|
||||||
const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env));
|
renderRuntimeBrandingMeta(branding),
|
||||||
return `${before}\n${links}\n ${after}`;
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Paperclip" />
|
<meta name="apple-mobile-web-app-title" content="Paperclip" />
|
||||||
<title>Paperclip</title>
|
<title>Paperclip</title>
|
||||||
|
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
|
||||||
|
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
|
||||||
<!-- PAPERCLIP_FAVICON_START -->
|
<!-- PAPERCLIP_FAVICON_START -->
|
||||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useCompany } from "../context/CompanyContext";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
@@ -206,14 +207,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
const assignee = issue.assigneeAgentId
|
const assignee = issue.assigneeAgentId
|
||||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||||
: null;
|
: null;
|
||||||
const userLabel = (userId: string | null | undefined) =>
|
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||||
userId
|
|
||||||
? userId === "local-board"
|
|
||||||
? "Board"
|
|
||||||
: currentUserId && userId === currentUserId
|
|
||||||
? "Me"
|
|
||||||
: userId.slice(0, 5)
|
|
||||||
: null;
|
|
||||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||||
|
|
||||||
@@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
>
|
>
|
||||||
No assignee
|
No assignee
|
||||||
</button>
|
</button>
|
||||||
{issue.createdByUserId && (
|
{currentUserId && (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
|
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
|
||||||
|
setAssigneeOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
Assign to me
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
@@ -361,7 +370,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
|
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{sortedAgents
|
{sortedAgents
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { formatDate, cn } from "../lib/utils";
|
import { formatDate, cn } from "../lib/utils";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
@@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] {
|
|||||||
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
|
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters(issues: Issue[], state: IssueViewState): Issue[] {
|
function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] {
|
||||||
let result = issues;
|
let result = issues;
|
||||||
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
|
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
|
||||||
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
|
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
|
||||||
if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId));
|
if (state.assignees.length > 0) {
|
||||||
|
result = result.filter((issue) => {
|
||||||
|
for (const assignee of state.assignees) {
|
||||||
|
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
|
||||||
|
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
|
||||||
|
if (issue.assigneeAgentId === assignee) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
|
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -165,6 +176,11 @@ export function IssuesList({
|
|||||||
}: IssuesListProps) {
|
}: IssuesListProps) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
|
||||||
// Scope the storage key per company so folding/view state is independent across companies.
|
// Scope the storage key per company so folding/view state is independent across companies.
|
||||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||||
@@ -224,9 +240,9 @@ export function IssuesList({
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||||
const filteredByControls = applyFilters(sourceIssues, viewState);
|
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||||
return sortIssues(filteredByControls, viewState);
|
return sortIssues(filteredByControls, viewState);
|
||||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch]);
|
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||||
|
|
||||||
const { data: labels } = useQuery({
|
const { data: labels } = useQuery({
|
||||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||||
@@ -253,13 +269,21 @@ export function IssuesList({
|
|||||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
||||||
}
|
}
|
||||||
// assignee
|
// assignee
|
||||||
const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned");
|
const groups = groupBy(
|
||||||
|
filtered,
|
||||||
|
(issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"),
|
||||||
|
);
|
||||||
return Object.keys(groups).map((key) => ({
|
return Object.keys(groups).map((key) => ({
|
||||||
key,
|
key,
|
||||||
label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)),
|
label:
|
||||||
|
key === "__unassigned"
|
||||||
|
? "Unassigned"
|
||||||
|
: key.startsWith("__user:")
|
||||||
|
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
|
||||||
|
: (agentName(key) ?? key.slice(0, 8)),
|
||||||
items: groups[key]!,
|
items: groups[key]!,
|
||||||
}));
|
}));
|
||||||
}, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
||||||
|
|
||||||
const newIssueDefaults = (groupKey?: string) => {
|
const newIssueDefaults = (groupKey?: string) => {
|
||||||
const defaults: Record<string, string> = {};
|
const defaults: Record<string, string> = {};
|
||||||
@@ -267,13 +291,16 @@ export function IssuesList({
|
|||||||
if (groupKey) {
|
if (groupKey) {
|
||||||
if (viewState.groupBy === "status") defaults.status = groupKey;
|
if (viewState.groupBy === "status") defaults.status = groupKey;
|
||||||
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
||||||
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey;
|
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
|
||||||
|
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||||
|
else defaults.assigneeAgentId = groupKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return defaults;
|
return defaults;
|
||||||
};
|
};
|
||||||
|
|
||||||
const assignIssue = (issueId: string, assigneeAgentId: string | null) => {
|
const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
||||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null });
|
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||||
setAssigneePickerIssueId(null);
|
setAssigneePickerIssueId(null);
|
||||||
setAssigneeSearch("");
|
setAssigneeSearch("");
|
||||||
};
|
};
|
||||||
@@ -419,22 +446,37 @@ export function IssuesList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assignee */}
|
{/* Assignee */}
|
||||||
{agents && agents.length > 0 && (
|
<div className="space-y-1">
|
||||||
<div className="space-y-1">
|
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||||
{agents.map((agent) => (
|
<Checkbox
|
||||||
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
checked={viewState.assignees.includes("__unassigned")}
|
||||||
<Checkbox
|
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
|
||||||
checked={viewState.assignees.includes(agent.id)}
|
/>
|
||||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
<span className="text-sm">No assignee</span>
|
||||||
/>
|
</label>
|
||||||
<span className="text-sm">{agent.name}</span>
|
{currentUserId && (
|
||||||
</label>
|
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||||
))}
|
<Checkbox
|
||||||
</div>
|
checked={viewState.assignees.includes("__me")}
|
||||||
|
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
|
||||||
|
/>
|
||||||
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-sm">Me</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(agents ?? []).map((agent) => (
|
||||||
|
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={viewState.assignees.includes(agent.id)}
|
||||||
|
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{agent.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{labels && labels.length > 0 && (
|
{labels && labels.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -683,6 +725,13 @@ export function IssuesList({
|
|||||||
>
|
>
|
||||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||||
|
) : issue.assigneeUserId ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||||
@@ -701,7 +750,7 @@ export function IssuesList({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||||
placeholder="Search agents..."
|
placeholder="Search assignees..."
|
||||||
value={assigneeSearch}
|
value={assigneeSearch}
|
||||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -710,16 +759,32 @@ export function IssuesList({
|
|||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||||
!issue.assigneeAgentId && "bg-accent",
|
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
assignIssue(issue.id, null);
|
assignIssue(issue.id, null, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No assignee
|
No assignee
|
||||||
</button>
|
</button>
|
||||||
|
{currentUserId && (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||||
|
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
assignIssue(issue.id, null, currentUserId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span>Me</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(agents ?? [])
|
{(agents ?? [])
|
||||||
.filter((agent) => {
|
.filter((agent) => {
|
||||||
if (!assigneeSearch.trim()) return true;
|
if (!assigneeSearch.trim()) return true;
|
||||||
@@ -737,7 +802,7 @@ export function IssuesList({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
assignIssue(issue.id, agent.id);
|
assignIssue(issue.id, agent.id, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { NewGoalDialog } from "./NewGoalDialog";
|
|||||||
import { NewAgentDialog } from "./NewAgentDialog";
|
import { NewAgentDialog } from "./NewAgentDialog";
|
||||||
import { ToastViewport } from "./ToastViewport";
|
import { ToastViewport } from "./ToastViewport";
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -22,6 +23,7 @@ import { useTheme } from "../context/ThemeContext";
|
|||||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||||
import { healthApi } from "../api/health";
|
import { healthApi } from "../api/health";
|
||||||
|
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||||
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 { NotFoundPage } from "../pages/NotFound";
|
||||||
@@ -36,6 +38,7 @@ export function Layout() {
|
|||||||
loading: companiesLoading,
|
loading: companiesLoading,
|
||||||
selectedCompany,
|
selectedCompany,
|
||||||
selectedCompanyId,
|
selectedCompanyId,
|
||||||
|
selectionSource,
|
||||||
setSelectedCompanyId,
|
setSelectedCompanyId,
|
||||||
} = useCompany();
|
} = useCompany();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@@ -88,7 +91,13 @@ export function Layout() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCompanyId !== matchedCompany.id) {
|
if (
|
||||||
|
shouldSyncCompanySelectionFromRoute({
|
||||||
|
selectionSource,
|
||||||
|
selectedCompanyId,
|
||||||
|
routeCompanyId: matchedCompany.id,
|
||||||
|
})
|
||||||
|
) {
|
||||||
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
|
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -99,6 +108,7 @@ export function Layout() {
|
|||||||
location.pathname,
|
location.pathname,
|
||||||
location.search,
|
location.search,
|
||||||
navigate,
|
navigate,
|
||||||
|
selectionSource,
|
||||||
selectedCompanyId,
|
selectedCompanyId,
|
||||||
setSelectedCompanyId,
|
setSelectedCompanyId,
|
||||||
]);
|
]);
|
||||||
@@ -214,7 +224,7 @@ export function Layout() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
||||||
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
|
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -223,145 +233,148 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
Skip to Main Content
|
Skip to Main Content
|
||||||
</a>
|
</a>
|
||||||
{/* Mobile backdrop */}
|
<WorktreeBanner />
|
||||||
{isMobile && sidebarOpen && (
|
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
||||||
<button
|
{/* Mobile backdrop */}
|
||||||
type="button"
|
{isMobile && sidebarOpen && (
|
||||||
className="fixed inset-0 z-40 bg-black/50"
|
<button
|
||||||
onClick={() => setSidebarOpen(false)}
|
type="button"
|
||||||
aria-label="Close sidebar"
|
className="fixed inset-0 z-40 bg-black/50"
|
||||||
/>
|
onClick={() => setSidebarOpen(false)}
|
||||||
)}
|
aria-label="Close sidebar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
|
||||||
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
|
|
||||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
||||||
<CompanyRail />
|
|
||||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<a
|
|
||||||
href="https://docs.paperclip.ing/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
|
||||||
>
|
|
||||||
<BookOpen className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">Documentation</span>
|
|
||||||
</a>
|
|
||||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
|
||||||
<Link
|
|
||||||
to="/instance/settings"
|
|
||||||
aria-label="Instance settings"
|
|
||||||
title="Instance settings"
|
|
||||||
onClick={() => {
|
|
||||||
if (isMobile) setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground shrink-0"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
aria-label={`Switch to ${nextTheme} mode`}
|
|
||||||
title={`Switch to ${nextTheme} mode`}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col shrink-0 h-full">
|
|
||||||
<div className="flex flex-1 min-h-0">
|
|
||||||
<CompanyRail />
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden transition-[width] duration-100 ease-out",
|
|
||||||
sidebarOpen ? "w-60" : "w-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-r border-border px-3 py-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<a
|
|
||||||
href="https://docs.paperclip.ing/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
|
||||||
>
|
|
||||||
<BookOpen className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">Documentation</span>
|
|
||||||
</a>
|
|
||||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
|
||||||
<Link
|
|
||||||
to="/instance/settings"
|
|
||||||
aria-label="Instance settings"
|
|
||||||
title="Instance settings"
|
|
||||||
onClick={() => {
|
|
||||||
if (isMobile) setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground shrink-0"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
aria-label={`Switch to ${nextTheme} mode`}
|
|
||||||
title={`Switch to ${nextTheme} mode`}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BreadcrumbBar />
|
|
||||||
</div>
|
|
||||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
|
||||||
<main
|
|
||||||
id="main-content"
|
|
||||||
tabIndex={-1}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 p-4 md:p-6",
|
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
|
||||||
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasUnknownCompanyPrefix ? (
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<NotFoundPage
|
<CompanyRail />
|
||||||
scope="invalid_company_prefix"
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||||
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
</div>
|
||||||
/>
|
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||||
) : (
|
<div className="flex items-center gap-1">
|
||||||
<Outlet />
|
<a
|
||||||
|
href="https://docs.paperclip.ing/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">Documentation</span>
|
||||||
|
</a>
|
||||||
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||||
|
<Link
|
||||||
|
to="/instance/settings"
|
||||||
|
aria-label="Instance settings"
|
||||||
|
title="Instance settings"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground shrink-0"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={`Switch to ${nextTheme} mode`}
|
||||||
|
title={`Switch to ${nextTheme} mode`}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col shrink-0">
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
<CompanyRail />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden transition-[width] duration-100 ease-out",
|
||||||
|
sidebarOpen ? "w-60" : "w-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-r border-border px-3 py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href="https://docs.paperclip.ing/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">Documentation</span>
|
||||||
|
</a>
|
||||||
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||||
|
<Link
|
||||||
|
to="/instance/settings"
|
||||||
|
aria-label="Instance settings"
|
||||||
|
title="Instance settings"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground shrink-0"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={`Switch to ${nextTheme} mode`}
|
||||||
|
title={`Switch to ${nextTheme} mode`}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||||
)}
|
)}
|
||||||
</main>
|
>
|
||||||
<PropertiesPanel />
|
<BreadcrumbBar />
|
||||||
|
</div>
|
||||||
|
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||||
|
<main
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 p-4 md:p-6",
|
||||||
|
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasUnknownCompanyPrefix ? (
|
||||||
|
<NotFoundPage
|
||||||
|
scope="invalid_company_prefix"
|
||||||
|
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Outlet />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<PropertiesPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { assetsApi } from "../api/assets";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import {
|
||||||
|
assigneeValueFromSelection,
|
||||||
|
currentUserAssigneeOption,
|
||||||
|
parseAssigneeValue,
|
||||||
|
} from "../lib/assignees";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -63,7 +68,8 @@ interface IssueDraft {
|
|||||||
description: string;
|
description: string;
|
||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
assigneeId: string;
|
assigneeValue: string;
|
||||||
|
assigneeId?: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
assigneeModelOverride: string;
|
assigneeModelOverride: string;
|
||||||
assigneeThinkingEffort: string;
|
assigneeThinkingEffort: string;
|
||||||
@@ -173,7 +179,7 @@ export function NewIssueDialog() {
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("todo");
|
const [status, setStatus] = useState("todo");
|
||||||
const [priority, setPriority] = useState("");
|
const [priority, setPriority] = useState("");
|
||||||
const [assigneeId, setAssigneeId] = useState("");
|
const [assigneeValue, setAssigneeValue] = useState("");
|
||||||
const [projectId, setProjectId] = useState("");
|
const [projectId, setProjectId] = useState("");
|
||||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||||
@@ -220,7 +226,11 @@ export function NewIssueDialog() {
|
|||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]);
|
||||||
|
const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId;
|
||||||
|
const selectedAssigneeUserId = selectedAssignee.assigneeUserId;
|
||||||
|
|
||||||
|
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null;
|
||||||
const supportsAssigneeOverrides = Boolean(
|
const supportsAssigneeOverrides = Boolean(
|
||||||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||||
);
|
);
|
||||||
@@ -295,7 +305,7 @@ export function NewIssueDialog() {
|
|||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
assigneeId,
|
assigneeValue,
|
||||||
projectId,
|
projectId,
|
||||||
assigneeModelOverride,
|
assigneeModelOverride,
|
||||||
assigneeThinkingEffort,
|
assigneeThinkingEffort,
|
||||||
@@ -307,7 +317,7 @@ export function NewIssueDialog() {
|
|||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
assigneeId,
|
assigneeValue,
|
||||||
projectId,
|
projectId,
|
||||||
assigneeModelOverride,
|
assigneeModelOverride,
|
||||||
assigneeThinkingEffort,
|
assigneeThinkingEffort,
|
||||||
@@ -330,7 +340,7 @@ export function NewIssueDialog() {
|
|||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
setProjectId(newIssueDefaults.projectId ?? "");
|
setProjectId(newIssueDefaults.projectId ?? "");
|
||||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
@@ -340,7 +350,11 @@ export function NewIssueDialog() {
|
|||||||
setDescription(draft.description);
|
setDescription(draft.description);
|
||||||
setStatus(draft.status || "todo");
|
setStatus(draft.status || "todo");
|
||||||
setPriority(draft.priority);
|
setPriority(draft.priority);
|
||||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
|
setAssigneeValue(
|
||||||
|
newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId
|
||||||
|
? assigneeValueFromSelection(newIssueDefaults)
|
||||||
|
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
|
||||||
|
);
|
||||||
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
||||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||||
@@ -350,7 +364,7 @@ export function NewIssueDialog() {
|
|||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
setProjectId(newIssueDefaults.projectId ?? "");
|
setProjectId(newIssueDefaults.projectId ?? "");
|
||||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
@@ -390,7 +404,7 @@ export function NewIssueDialog() {
|
|||||||
setDescription("");
|
setDescription("");
|
||||||
setStatus("todo");
|
setStatus("todo");
|
||||||
setPriority("");
|
setPriority("");
|
||||||
setAssigneeId("");
|
setAssigneeValue("");
|
||||||
setProjectId("");
|
setProjectId("");
|
||||||
setAssigneeOptionsOpen(false);
|
setAssigneeOptionsOpen(false);
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
@@ -406,7 +420,7 @@ export function NewIssueDialog() {
|
|||||||
function handleCompanyChange(companyId: string) {
|
function handleCompanyChange(companyId: string) {
|
||||||
if (companyId === effectiveCompanyId) return;
|
if (companyId === effectiveCompanyId) return;
|
||||||
setDialogCompanyId(companyId);
|
setDialogCompanyId(companyId);
|
||||||
setAssigneeId("");
|
setAssigneeValue("");
|
||||||
setProjectId("");
|
setProjectId("");
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
@@ -443,7 +457,8 @@ export function NewIssueDialog() {
|
|||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
priority: priority || "medium",
|
priority: priority || "medium",
|
||||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||||
|
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||||
...(projectId ? { projectId } : {}),
|
...(projectId ? { projectId } : {}),
|
||||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||||
@@ -475,7 +490,9 @@ export function NewIssueDialog() {
|
|||||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
||||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||||
const currentPriority = priorities.find((p) => p.value === priority);
|
const currentPriority = priorities.find((p) => p.value === priority);
|
||||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
const currentAssignee = selectedAssigneeAgentId
|
||||||
|
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
|
||||||
|
: null;
|
||||||
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||||
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
|
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
|
||||||
? currentProject?.executionWorkspacePolicy ?? null
|
? currentProject?.executionWorkspacePolicy ?? null
|
||||||
@@ -497,16 +514,18 @@ export function NewIssueDialog() {
|
|||||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
|
||||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() => [
|
||||||
sortAgentsByRecency(
|
...currentUserAssigneeOption(currentUserId),
|
||||||
|
...sortAgentsByRecency(
|
||||||
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||||
recentAssigneeIds,
|
recentAssigneeIds,
|
||||||
).map((agent) => ({
|
).map((agent) => ({
|
||||||
id: agent.id,
|
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
||||||
label: agent.name,
|
label: agent.name,
|
||||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||||
})),
|
})),
|
||||||
[agents, recentAssigneeIds],
|
],
|
||||||
|
[agents, currentUserId, recentAssigneeIds],
|
||||||
);
|
);
|
||||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -710,7 +729,16 @@ export function NewIssueDialog() {
|
|||||||
}
|
}
|
||||||
if (e.key === "Tab" && !e.shiftKey) {
|
if (e.key === "Tab" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
assigneeSelectorRef.current?.focus();
|
if (assigneeValue) {
|
||||||
|
// Assignee already set — skip to project or description
|
||||||
|
if (projectId) {
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
projectSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assigneeSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -723,33 +751,49 @@ export function NewIssueDialog() {
|
|||||||
<span>For</span>
|
<span>For</span>
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
ref={assigneeSelectorRef}
|
ref={assigneeSelectorRef}
|
||||||
value={assigneeId}
|
value={assigneeValue}
|
||||||
options={assigneeOptions}
|
options={assigneeOptions}
|
||||||
placeholder="Assignee"
|
placeholder="Assignee"
|
||||||
disablePortal
|
disablePortal
|
||||||
noneLabel="No assignee"
|
noneLabel="No assignee"
|
||||||
searchPlaceholder="Search assignees..."
|
searchPlaceholder="Search assignees..."
|
||||||
emptyMessage="No assignees found."
|
emptyMessage="No assignees found."
|
||||||
onChange={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }}
|
onChange={(value) => {
|
||||||
|
const nextAssignee = parseAssigneeValue(value);
|
||||||
|
if (nextAssignee.assigneeAgentId) {
|
||||||
|
trackRecentAssignee(nextAssignee.assigneeAgentId);
|
||||||
|
}
|
||||||
|
setAssigneeValue(value);
|
||||||
|
}}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
projectSelectorRef.current?.focus();
|
if (projectId) {
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
projectSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
renderTriggerValue={(option) =>
|
renderTriggerValue={(option) =>
|
||||||
option && currentAssignee ? (
|
option ? (
|
||||||
<>
|
currentAssignee ? (
|
||||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<>
|
||||||
|
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<span className="truncate">{option.label}</span>
|
<span className="truncate">{option.label}</span>
|
||||||
</>
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">Assignee</span>
|
<span className="text-muted-foreground">Assignee</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
renderOption={(option) => {
|
renderOption={(option) => {
|
||||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
const assignee = (agents ?? []).find((agent) => agent.id === option.id);
|
const assignee = parseAssigneeValue(option.id).assigneeAgentId
|
||||||
|
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AgentIcon icon={assignee?.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||||
<span className="truncate">{option.label}</span>
|
<span className="truncate">{option.label}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -494,23 +494,41 @@ export function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep3Next() {
|
async function handleStep3Next() {
|
||||||
|
if (!createdCompanyId || !createdAgentId) return;
|
||||||
|
setError(null);
|
||||||
|
setStep(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLaunch() {
|
||||||
if (!createdCompanyId || !createdAgentId) return;
|
if (!createdCompanyId || !createdAgentId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const issue = await issuesApi.create(createdCompanyId, {
|
let issueRef = createdIssueRef;
|
||||||
title: taskTitle.trim(),
|
if (!issueRef) {
|
||||||
...(taskDescription.trim()
|
const issue = await issuesApi.create(createdCompanyId, {
|
||||||
? { description: taskDescription.trim() }
|
title: taskTitle.trim(),
|
||||||
: {}),
|
...(taskDescription.trim()
|
||||||
assigneeAgentId: createdAgentId,
|
? { description: taskDescription.trim() }
|
||||||
status: "todo"
|
: {}),
|
||||||
});
|
assigneeAgentId: createdAgentId,
|
||||||
setCreatedIssueRef(issue.identifier ?? issue.id);
|
status: "todo"
|
||||||
queryClient.invalidateQueries({
|
});
|
||||||
queryKey: queryKeys.issues.list(createdCompanyId)
|
issueRef = issue.identifier ?? issue.id;
|
||||||
});
|
setCreatedIssueRef(issueRef);
|
||||||
setStep(4);
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.issues.list(createdCompanyId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCompanyId(createdCompanyId);
|
||||||
|
reset();
|
||||||
|
closeOnboarding();
|
||||||
|
navigate(
|
||||||
|
createdCompanyPrefix
|
||||||
|
? `/${createdCompanyPrefix}/issues/${issueRef}`
|
||||||
|
: `/issues/${issueRef}`
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to create task");
|
setError(err instanceof Error ? err.message : "Failed to create task");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -518,20 +536,6 @@ export function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLaunch() {
|
|
||||||
if (!createdAgentId) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setLoading(false);
|
|
||||||
reset();
|
|
||||||
closeOnboarding();
|
|
||||||
if (createdCompanyPrefix) {
|
|
||||||
navigate(`/${createdCompanyPrefix}/dashboard`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate("/dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1175,8 +1179,8 @@ export function OnboardingWizard() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">Ready to launch</h3>
|
<h3 className="font-medium">Ready to launch</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Everything is set up. Your assigned task already woke
|
Everything is set up. Launching now will create the
|
||||||
the agent, so you can jump straight to the issue.
|
starter task, wake the agent, and open the issue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1291,7 +1295,7 @@ export function OnboardingWizard() {
|
|||||||
) : (
|
) : (
|
||||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||||
)}
|
)}
|
||||||
{loading ? "Opening..." : "Open Issue"}
|
{loading ? "Creating..." : "Create & Open Issue"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
25
ui/src/components/WorktreeBanner.tsx
Normal file
25
ui/src/components/WorktreeBanner.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
||||||
|
|
||||||
|
export function WorktreeBanner() {
|
||||||
|
const branding = getWorktreeUiBranding();
|
||||||
|
if (!branding) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden border-b px-3 py-1.5 text-[11px] font-medium tracking-[0.2em] uppercase"
|
||||||
|
style={{
|
||||||
|
backgroundColor: branding.color,
|
||||||
|
color: branding.textColor,
|
||||||
|
borderColor: `${branding.textColor}22`,
|
||||||
|
boxShadow: `inset 0 -1px 0 ${branding.textColor}18`,
|
||||||
|
backgroundImage: `linear-gradient(90deg, ${branding.textColor}14, transparent 28%, transparent 72%, ${branding.textColor}12), repeating-linear-gradient(135deg, transparent 0 10px, ${branding.textColor}08 10px 20px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
|
<span className="shrink-0 opacity-70">Worktree</span>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
|
||||||
|
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,8 +12,7 @@ import type { Company } from "@paperclipai/shared";
|
|||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import type { CompanySelectionSource } from "../lib/company-selection";
|
||||||
type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
|
|
||||||
type CompanySelectionOptions = { source?: CompanySelectionSource };
|
type CompanySelectionOptions = { source?: CompanySelectionSource };
|
||||||
|
|
||||||
interface CompanyContextValue {
|
interface CompanyContextValue {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface NewIssueDefaults {
|
|||||||
priority?: string;
|
priority?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
|
assigneeUserId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal file
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getRememberedPathOwnerCompanyId,
|
||||||
|
sanitizeRememberedPathForCompany,
|
||||||
|
} from "../lib/company-page-memory";
|
||||||
|
|
||||||
|
const companies = [
|
||||||
|
{ id: "for", issuePrefix: "FOR" },
|
||||||
|
{ id: "pap", issuePrefix: "PAP" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("getRememberedPathOwnerCompanyId", () => {
|
||||||
|
it("uses the route company instead of stale selected-company state for prefixed routes", () => {
|
||||||
|
expect(
|
||||||
|
getRememberedPathOwnerCompanyId({
|
||||||
|
companies,
|
||||||
|
pathname: "/FOR/issues/FOR-1",
|
||||||
|
fallbackCompanyId: "pap",
|
||||||
|
}),
|
||||||
|
).toBe("for");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips saving when a prefixed route cannot yet be resolved to a known company", () => {
|
||||||
|
expect(
|
||||||
|
getRememberedPathOwnerCompanyId({
|
||||||
|
companies: [],
|
||||||
|
pathname: "/FOR/issues/FOR-1",
|
||||||
|
fallbackCompanyId: "pap",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the previous company for unprefixed board routes", () => {
|
||||||
|
expect(
|
||||||
|
getRememberedPathOwnerCompanyId({
|
||||||
|
companies,
|
||||||
|
pathname: "/dashboard",
|
||||||
|
fallbackCompanyId: "pap",
|
||||||
|
}),
|
||||||
|
).toBe("pap");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeRememberedPathForCompany", () => {
|
||||||
|
it("keeps remembered issue paths that belong to the target company", () => {
|
||||||
|
expect(
|
||||||
|
sanitizeRememberedPathForCompany({
|
||||||
|
path: "/issues/PAP-12",
|
||||||
|
companyPrefix: "PAP",
|
||||||
|
}),
|
||||||
|
).toBe("/issues/PAP-12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to dashboard for remembered issue identifiers from another company", () => {
|
||||||
|
expect(
|
||||||
|
sanitizeRememberedPathForCompany({
|
||||||
|
path: "/issues/FOR-1",
|
||||||
|
companyPrefix: "PAP",
|
||||||
|
}),
|
||||||
|
).toBe("/dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to dashboard when no remembered path exists", () => {
|
||||||
|
expect(
|
||||||
|
sanitizeRememberedPathForCompany({
|
||||||
|
path: null,
|
||||||
|
companyPrefix: "PAP",
|
||||||
|
}),
|
||||||
|
).toBe("/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useLocation, useNavigate } from "@/lib/router";
|
import { useLocation, useNavigate } from "@/lib/router";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||||
|
import {
|
||||||
|
getRememberedPathOwnerCompanyId,
|
||||||
|
isRememberableCompanyPath,
|
||||||
|
sanitizeRememberedPathForCompany,
|
||||||
|
} from "../lib/company-page-memory";
|
||||||
|
|
||||||
const STORAGE_KEY = "paperclip.companyPaths";
|
const STORAGE_KEY = "paperclip.companyPaths";
|
||||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
|
||||||
|
|
||||||
function getCompanyPaths(): Record<string, string> {
|
function getCompanyPaths(): Record<string, string> {
|
||||||
try {
|
try {
|
||||||
@@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remembers the last visited page per company and navigates to it on company switch.
|
* Remembers the last visited page per company and navigates to it on company switch.
|
||||||
* Falls back to /dashboard if no page was previously visited for a company.
|
* Falls back to /dashboard if no page was previously visited for a company.
|
||||||
*/
|
*/
|
||||||
export function useCompanyPageMemory() {
|
export function useCompanyPageMemory() {
|
||||||
const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const prevCompanyId = useRef<string | null>(selectedCompanyId);
|
const prevCompanyId = useRef<string | null>(selectedCompanyId);
|
||||||
|
const rememberedPathOwnerCompanyId = useMemo(
|
||||||
|
() =>
|
||||||
|
getRememberedPathOwnerCompanyId({
|
||||||
|
companies,
|
||||||
|
pathname: location.pathname,
|
||||||
|
fallbackCompanyId: prevCompanyId.current,
|
||||||
|
}),
|
||||||
|
[companies, location.pathname],
|
||||||
|
);
|
||||||
|
|
||||||
// Save current path for current company on every location change.
|
// Save current path for current company on every location change.
|
||||||
// Uses prevCompanyId ref so we save under the correct company even
|
// Uses prevCompanyId ref so we save under the correct company even
|
||||||
// during the render where selectedCompanyId has already changed.
|
// during the render where selectedCompanyId has already changed.
|
||||||
const fullPath = location.pathname + location.search;
|
const fullPath = location.pathname + location.search;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const companyId = prevCompanyId.current;
|
const companyId = rememberedPathOwnerCompanyId;
|
||||||
const relativePath = toCompanyRelativePath(fullPath);
|
const relativePath = toCompanyRelativePath(fullPath);
|
||||||
if (companyId && isRememberableCompanyPath(relativePath)) {
|
if (companyId && isRememberableCompanyPath(relativePath)) {
|
||||||
saveCompanyPath(companyId, relativePath);
|
saveCompanyPath(companyId, relativePath);
|
||||||
}
|
}
|
||||||
}, [fullPath]);
|
}, [fullPath, rememberedPathOwnerCompanyId]);
|
||||||
|
|
||||||
// Navigate to saved path when company changes
|
// Navigate to saved path when company changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,9 +67,10 @@ export function useCompanyPageMemory() {
|
|||||||
) {
|
) {
|
||||||
if (selectionSource !== "route_sync" && selectedCompany) {
|
if (selectionSource !== "route_sync" && selectedCompany) {
|
||||||
const paths = getCompanyPaths();
|
const paths = getCompanyPaths();
|
||||||
const savedPath = paths[selectedCompanyId];
|
const targetPath = sanitizeRememberedPathForCompany({
|
||||||
const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard";
|
path: paths[selectedCompanyId],
|
||||||
const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard";
|
companyPrefix: selectedCompany.issuePrefix,
|
||||||
|
});
|
||||||
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
|
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -304,8 +304,7 @@ export function IssueDetail() {
|
|||||||
options.push({ id: `agent:${agent.id}`, label: agent.name });
|
options.push({ id: `agent:${agent.id}`, label: agent.name });
|
||||||
}
|
}
|
||||||
if (currentUserId) {
|
if (currentUserId) {
|
||||||
const label = currentUserId === "local-board" ? "Board" : "Me (Board)";
|
options.push({ id: `user:${currentUserId}`, label: "Me" });
|
||||||
options.push({ id: `user:${currentUserId}`, label });
|
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}, [agents, currentUserId]);
|
}, [agents, currentUserId]);
|
||||||
|
|||||||
Reference in New Issue
Block a user