From ef0846e723750a3f6d97d6c0e54a8ec9598b2711 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 07:20:14 -0500 Subject: [PATCH 01/21] Remove priority icon from issue rows across the app Priority is still supported as a feature (editable in issue properties, used in filters), but no longer shown prominently in every issue row. Affects inbox, issues list, my issues, and dashboard pages. Co-Authored-By: Paperclip --- ui/src/components/IssueRow.tsx | 4 ---- ui/src/components/IssuesList.tsx | 3 --- ui/src/pages/Dashboard.tsx | 3 +-- ui/src/pages/Inbox.tsx | 5 +---- ui/src/pages/MyIssues.tsx | 7 ++----- 5 files changed, 4 insertions(+), 18 deletions(-) diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 5de9bf00..ee351d57 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { cn } from "../lib/utils"; -import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; type UnreadState = "hidden" | "visible" | "fading"; @@ -61,9 +60,6 @@ export function IssueRow({ ) : null} {desktopMetaLeading ?? ( <> - - - diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 2b44a2f2..c7f75f1e 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -680,9 +680,6 @@ export function IssuesList({ )} desktopMetaLeading={( <> - - - { diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 45613380..c30f3bb4 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -14,7 +14,7 @@ import { queryKeys } from "../lib/queryKeys"; import { MetricCard } from "../components/MetricCard"; import { EmptyState } from "../components/EmptyState"; import { StatusIcon } from "../components/StatusIcon"; -import { PriorityIcon } from "../components/PriorityIcon"; + import { ActivityRow } from "../components/ActivityRow"; import { Identity } from "../components/Identity"; import { timeAgo } from "../lib/timeAgo"; @@ -356,7 +356,6 @@ export function Dashboard() { {issue.title} - {issue.identifier ?? issue.id.slice(0, 8)} diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index d2a6ce20..f70441f6 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -15,7 +15,7 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueRow } from "../components/IssueRow"; -import { PriorityIcon } from "../components/PriorityIcon"; + import { StatusIcon } from "../components/StatusIcon"; import { StatusBadge } from "../components/StatusBadge"; import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload"; @@ -767,9 +767,6 @@ export function Inbox() { issueLinkState={issueLinkState} desktopMetaLeading={( <> - - - diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx index ea717c6d..301c526f 100644 --- a/ui/src/pages/MyIssues.tsx +++ b/ui/src/pages/MyIssues.tsx @@ -5,7 +5,7 @@ import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "../components/StatusIcon"; -import { PriorityIcon } from "../components/PriorityIcon"; + import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; @@ -56,10 +56,7 @@ export function MyIssues() { title={issue.title} to={`/issues/${issue.identifier ?? issue.id}`} leading={ - <> - - - + } trailing={ From 0b9f00346b04cd7194f3b9029e8a02d714380500 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 08:12:49 -0500 Subject: [PATCH 02/21] Increase monospace font size and add dark mode background for inline code Bump monospace font-size from 0.78em to 1.1em across all markdown contexts (editor, code blocks, inline code). Add subtle gray background (#ffffff0f) for inline code in dark mode. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/index.css | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/src/index.css b/ui/src/index.css index 2b3e6171..ea0e6e59 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -415,7 +415,7 @@ .paperclip-mdxeditor-content code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 0.78em; + font-size: 1.1em; } .paperclip-mdxeditor-content pre { @@ -433,7 +433,7 @@ .paperclip-mdxeditor .cm-editor { background-color: #1e1e2e !important; color: #cdd6f4 !important; - font-size: 0.78em; + font-size: 1.1em; } .paperclip-mdxeditor .cm-gutters { @@ -513,14 +513,14 @@ color: #cdd6f4 !important; padding: 0.5rem 0.65rem !important; margin: 0.4rem 0 !important; - font-size: 0.78em !important; + font-size: 1.1em !important; overflow-x: auto; white-space: pre; } .paperclip-markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 0.78em; + font-size: 1.1em; } .paperclip-markdown pre code { @@ -543,6 +543,10 @@ font-weight: 500; } +.dark .prose :not(pre) > code { + background-color: #ffffff0f; +} + .paperclip-markdown { color: var(--foreground); font-size: 0.9375rem; From f8dd4dcb308f553bcc146fef7f5fd88e647d8957 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 11:38:27 -0500 Subject: [PATCH 03/21] Reduce monospace font size from 1.1em to 1em MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.1em was too large per feedback — settle on 1em for all markdown monospace contexts. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/index.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/index.css b/ui/src/index.css index ea0e6e59..c8eb8366 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -415,7 +415,7 @@ .paperclip-mdxeditor-content code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 1.1em; + font-size: 1em; } .paperclip-mdxeditor-content pre { @@ -433,7 +433,7 @@ .paperclip-mdxeditor .cm-editor { background-color: #1e1e2e !important; color: #cdd6f4 !important; - font-size: 1.1em; + font-size: 1em; } .paperclip-mdxeditor .cm-gutters { @@ -513,14 +513,14 @@ color: #cdd6f4 !important; padding: 0.5rem 0.65rem !important; margin: 0.4rem 0 !important; - font-size: 1.1em !important; + font-size: 1em !important; overflow-x: auto; white-space: pre; } .paperclip-markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 1.1em; + font-size: 1em; } .paperclip-markdown pre code { From cd7c6ee75139e7c82e1165d8392b5aa89b99624c Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 12:51:05 -0500 Subject: [PATCH 04/21] Fix login form not being detected by 1Password Add name, id, and htmlFor attributes to form inputs and a method/action to the form element so password managers can properly identify the login form fields. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Auth.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index 7f794a70..048d10c5 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -89,6 +89,8 @@ export function AuthPage() {
{ event.preventDefault(); if (mutation.isPending) return; @@ -101,8 +103,10 @@ export function AuthPage() { > {mode === "sign_up" && (
- + setName(event.target.value)} @@ -112,8 +116,10 @@ export function AuthPage() {
)}
- +
- + Date: Sat, 21 Mar 2026 14:48:10 -0500 Subject: [PATCH 05/21] Fix markdown mention chips Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 5 + packages/shared/src/project-mentions.test.ts | 29 +++ packages/shared/src/project-mentions.ts | 57 +++++ server/src/services/issues.ts | 15 +- ui/src/components/AgentIconPicker.tsx | 93 +------- ui/src/components/CommentThread.tsx | 5 +- ui/src/components/MarkdownBody.test.tsx | 18 ++ ui/src/components/MarkdownBody.tsx | 48 ++-- ui/src/components/MarkdownEditor.tsx | 228 ++++++++----------- ui/src/components/NewIssueDialog.tsx | 2 + ui/src/index.css | 31 +++ ui/src/lib/agent-icons.ts | 98 ++++++++ ui/src/lib/mention-chips.ts | 160 +++++++++++++ ui/src/pages/IssueDetail.tsx | 2 + 14 files changed, 527 insertions(+), 264 deletions(-) create mode 100644 packages/shared/src/project-mentions.test.ts create mode 100644 ui/src/lib/agent-icons.ts create mode 100644 ui/src/lib/mention-chips.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6a105d79..8ba20ba9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -535,10 +535,15 @@ export { API_PREFIX, API } from "./api.js"; export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js"; export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js"; export { + AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, + buildAgentMentionHref, buildProjectMentionHref, + extractAgentMentionIds, + parseAgentMentionHref, parseProjectMentionHref, extractProjectMentionIds, + type ParsedAgentMention, type ParsedProjectMention, } from "./project-mentions.js"; diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts new file mode 100644 index 00000000..55f27369 --- /dev/null +++ b/packages/shared/src/project-mentions.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentMentionHref, + buildProjectMentionHref, + extractAgentMentionIds, + extractProjectMentionIds, + parseAgentMentionHref, + parseProjectMentionHref, +} from "./project-mentions.js"; + +describe("project-mentions", () => { + it("round-trips project mentions with color metadata", () => { + const href = buildProjectMentionHref("project-123", "#336699"); + expect(parseProjectMentionHref(href)).toEqual({ + projectId: "project-123", + color: "#336699", + }); + expect(extractProjectMentionIds(`[@Paperclip App](${href})`)).toEqual(["project-123"]); + }); + + it("round-trips agent mentions with icon metadata", () => { + const href = buildAgentMentionHref("agent-123", "code"); + expect(parseAgentMentionHref(href)).toEqual({ + agentId: "agent-123", + icon: "code", + }); + expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); + }); +}); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 2c167517..66be8948 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,16 +1,24 @@ export const PROJECT_MENTION_SCHEME = "project://"; +export const AGENT_MENTION_SCHEME = "agent://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; +const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; export interface ParsedProjectMention { projectId: string; color: string | null; } +export interface ParsedAgentMention { + agentId: string; + icon: string | null; +} + function normalizeHexColor(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); @@ -65,6 +73,36 @@ export function parseProjectMentionHref(href: string): ParsedProjectMention | nu }; } +export function buildAgentMentionHref(agentId: string, icon?: string | null): string { + const trimmedAgentId = agentId.trim(); + const normalizedIcon = normalizeAgentIcon(icon ?? null); + if (!normalizedIcon) { + return `${AGENT_MENTION_SCHEME}${trimmedAgentId}`; + } + return `${AGENT_MENTION_SCHEME}${trimmedAgentId}?i=${encodeURIComponent(normalizedIcon)}`; +} + +export function parseAgentMentionHref(href: string): ParsedAgentMention | null { + if (!href.startsWith(AGENT_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "agent:") return null; + + const agentId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!agentId) return null; + + return { + agentId, + icon: normalizeAgentIcon(url.searchParams.get("i") ?? url.searchParams.get("icon")), + }; +} + export function extractProjectMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); @@ -76,3 +114,22 @@ export function extractProjectMentionIds(markdown: string): string[] { } return [...ids]; } + +export function extractAgentMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(AGENT_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseAgentMentionHref(match[1]); + if (parsed) ids.add(parsed.agentId); + } + return [...ids]; +} + +function normalizeAgentIcon(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().toLowerCase(); + if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null; + return trimmed; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 681da27d..02a0cb9e 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -19,7 +19,7 @@ import { projectWorkspaces, projects, } from "@paperclipai/db"; -import { extractProjectMentionIds } from "@paperclipai/shared"; +import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, @@ -1462,10 +1462,19 @@ export function issueService(db: Db) { const tokens = new Set(); let m: RegExpExecArray | null; while ((m = re.exec(body)) !== null) tokens.add(m[1].toLowerCase()); - if (tokens.size === 0) return []; + + const explicitAgentMentionIds = extractAgentMentionIds(body); + if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return []; + const rows = await db.select({ id: agents.id, name: agents.name }) .from(agents).where(eq(agents.companyId, companyId)); - return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id); + const resolved = new Set(explicitAgentMentionIds); + for (const agent of rows) { + if (tokens.has(agent.name.toLowerCase())) { + resolved.add(agent.id); + } + } + return [...resolved]; }, findMentionedProjectIds: async (issueId: string) => { diff --git a/ui/src/components/AgentIconPicker.tsx b/ui/src/components/AgentIconPicker.tsx index 8f53d87d..06257fb9 100644 --- a/ui/src/components/AgentIconPicker.tsx +++ b/ui/src/components/AgentIconPicker.tsx @@ -1,46 +1,5 @@ import { useState, useMemo } from "react"; import { - Bot, - Cpu, - Brain, - Zap, - Rocket, - Code, - Terminal, - Shield, - Eye, - Search, - Wrench, - Hammer, - Lightbulb, - Sparkles, - Star, - Heart, - Flame, - Bug, - Cog, - Database, - Globe, - Lock, - Mail, - MessageSquare, - FileCode, - GitBranch, - Package, - Puzzle, - Target, - Wand2, - Atom, - CircuitBoard, - Radar, - Swords, - Telescope, - Microscope, - Crown, - Gem, - Hexagon, - Pentagon, - Fingerprint, type LucideIcon, } from "lucide-react"; import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared"; @@ -51,60 +10,10 @@ import { } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; - -export const AGENT_ICONS: Record = { - bot: Bot, - cpu: Cpu, - brain: Brain, - zap: Zap, - rocket: Rocket, - code: Code, - terminal: Terminal, - shield: Shield, - eye: Eye, - search: Search, - wrench: Wrench, - hammer: Hammer, - lightbulb: Lightbulb, - sparkles: Sparkles, - star: Star, - heart: Heart, - flame: Flame, - bug: Bug, - cog: Cog, - database: Database, - globe: Globe, - lock: Lock, - mail: Mail, - "message-square": MessageSquare, - "file-code": FileCode, - "git-branch": GitBranch, - package: Package, - puzzle: Puzzle, - target: Target, - wand: Wand2, - atom: Atom, - "circuit-board": CircuitBoard, - radar: Radar, - swords: Swords, - telescope: Telescope, - microscope: Microscope, - crown: Crown, - gem: Gem, - hexagon: Hexagon, - pentagon: Pentagon, - fingerprint: Fingerprint, -}; +import { AGENT_ICONS, getAgentIcon } from "../lib/agent-icons"; const DEFAULT_ICON: AgentIconName = "bot"; -export function getAgentIcon(iconName: string | null | undefined): LucideIcon { - if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) { - return AGENT_ICONS[iconName as AgentIconName]; - } - return AGENT_ICONS[DEFAULT_ICON]; -} - interface AgentIconProps { icon: string | null | undefined; className?: string; diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index eda28518..cdf0ddd2 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -311,8 +311,11 @@ export function CommentThread({ return Array.from(agentMap.values()) .filter((a) => a.status !== "terminated") .map((a) => ({ - id: a.id, + id: `agent:${a.id}`, name: a.name, + kind: "agent", + agentId: a.id, + agentIcon: a.icon, })); }, [agentMap, providedMentions]); diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 06cfc70a..5794f989 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; @@ -28,4 +29,21 @@ describe("MarkdownBody", () => { expect(html).toContain('src="/resolved/images/org-chart.png"'); expect(html).toContain('alt="Org chart"'); }); + + it("renders agent and project mentions as chips", () => { + const html = renderToStaticMarkup( + + + {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`} + + , + ); + + expect(html).toContain('href="/agents/agent-123"'); + expect(html).toContain('data-mention-kind="agent"'); + expect(html).toContain("--paperclip-mention-icon-mask"); + expect(html).toContain('href="/projects/project-456"'); + expect(html).toContain('data-mention-kind="project"'); + expect(html).toContain("--paperclip-mention-project-color:#336699"); + }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 0fbb52c4..e00afc84 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,9 +1,9 @@ -import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react"; +import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import { parseProjectMentionHref } from "@paperclipai/shared"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; +import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; interface MarkdownBodyProps { children: string; @@ -36,29 +36,6 @@ function extractMermaidSource(children: ReactNode): string | null { return flattenText(childProps.children).replace(/\n$/, ""); } -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); - if (!match) return null; - const value = match[1]; - return { - r: parseInt(value.slice(0, 2), 16), - g: parseInt(value.slice(2, 4), 16), - b: parseInt(value.slice(4, 6), 16), - }; -} - -function mentionChipStyle(color: string | null): CSSProperties | undefined { - if (!color) return undefined; - const rgb = hexToRgb(color); - if (!rgb) return undefined; - const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; - return { - borderColor: color, - backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, - color: luminance > 0.55 ? "#111827" : "#f8fafc", - }; -} - function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -125,16 +102,23 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB return
{preChildren}
; }, a: ({ href, children: linkChildren }) => { - const parsed = href ? parseProjectMentionHref(href) : null; + const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { - const label = linkChildren; + const targetHref = parsed.kind === "project" + ? `/projects/${parsed.projectId}` + : `/agents/${parsed.agentId}`; return ( - {label} + {linkChildren} ); } @@ -160,7 +144,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB className, )} > - + url}> {children}
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 29a57a3a..0f6bd402 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -6,7 +6,6 @@ import { useMemo, useRef, useState, - type CSSProperties, type DragEvent, } from "react"; import { @@ -27,7 +26,9 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; -import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; +import { AgentIcon } from "./AgentIconPicker"; +import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { cn } from "../lib/utils"; /* ---- Mention types ---- */ @@ -36,6 +37,8 @@ export interface MentionOption { id: string; name: string; kind?: "agent" | "project"; + agentId?: string; + agentIcon?: string | null; projectId?: string; projectColor?: string | null; } @@ -154,7 +157,8 @@ function mentionMarkdown(option: MentionOption): string { if (option.kind === "project" && option.projectId) { return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `; } - return `@${option.name} `; + const agentId = option.agentId ?? option.id.replace(/^agent:/, ""); + return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; } /** Replace `@` in the markdown string with the selected mention token. */ @@ -166,31 +170,6 @@ function applyMention(markdown: string, query: string, option: MentionOption): s return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); } -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const trimmed = hex.trim(); - const match = /^#([0-9a-f]{6})$/i.exec(trimmed); - if (!match) return null; - const value = match[1]; - return { - r: parseInt(value.slice(0, 2), 16), - g: parseInt(value.slice(2, 4), 16), - b: parseInt(value.slice(4, 6), 16), - }; -} - -function mentionChipStyle(color: string | null): CSSProperties | undefined { - if (!color) return undefined; - const rgb = hexToRgb(color); - if (!rgb) return undefined; - const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; - const textColor = luminance > 0.55 ? "#111827" : "#f8fafc"; - return { - borderColor: color, - backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, - color: textColor, - }; -} - /* ---- Component ---- */ export const MarkdownEditor = forwardRef(function MarkdownEditor({ @@ -221,11 +200,15 @@ export const MarkdownEditor = forwardRef const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); const mentionActive = mentionState !== null && mentions && mentions.length > 0; - const projectColorById = useMemo(() => { - const map = new Map(); + const mentionOptionByKey = useMemo(() => { + const map = new Map(); for (const mention of mentions ?? []) { + if (mention.kind === "agent") { + const agentId = mention.agentId ?? mention.id.replace(/^agent:/, ""); + map.set(`agent:${agentId}`, mention); + } if (mention.kind === "project" && mention.projectId) { - map.set(mention.projectId, mention.projectColor ?? null); + map.set(`project:${mention.projectId}`, mention); } } return map; @@ -315,31 +298,28 @@ export const MarkdownEditor = forwardRef const links = editable.querySelectorAll("a"); for (const node of links) { const link = node as HTMLAnchorElement; - const parsed = parseProjectMentionHref(link.getAttribute("href") ?? ""); + const parsed = parseMentionChipHref(link.getAttribute("href") ?? ""); if (!parsed) { - if (link.dataset.projectMention === "true") { - link.dataset.projectMention = "false"; - link.classList.remove("paperclip-project-mention-chip"); - link.removeAttribute("contenteditable"); - link.style.removeProperty("border-color"); - link.style.removeProperty("background-color"); - link.style.removeProperty("color"); - } + clearMentionChipDecoration(link); continue; } - const color = parsed.color ?? projectColorById.get(parsed.projectId) ?? null; - link.dataset.projectMention = "true"; - link.classList.add("paperclip-project-mention-chip"); - link.setAttribute("contenteditable", "false"); - const style = mentionChipStyle(color); - if (style) { - link.style.borderColor = style.borderColor ?? ""; - link.style.backgroundColor = style.backgroundColor ?? ""; - link.style.color = style.color ?? ""; + if (parsed.kind === "project") { + const option = mentionOptionByKey.get(`project:${parsed.projectId}`); + applyMentionChipDecoration(link, { + ...parsed, + color: parsed.color ?? option?.projectColor ?? null, + }); + continue; } + + const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); + applyMentionChipDecoration(link, { + ...parsed, + icon: parsed.icon ?? option?.agentIcon ?? null, + }); } - }, [projectColorById]); + }, [mentionOptionByKey]); // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { @@ -395,94 +375,67 @@ export const MarkdownEditor = forwardRef // update state between the last render and this callback firing). const state = mentionStateRef.current; if (!state) return; - - if (option.kind === "project" && option.projectId) { - const current = latestValueRef.current; - const next = applyMention(current, state.query, option); - if (next !== current) { - latestValueRef.current = next; - ref.current?.setMarkdown(next); - onChange(next); - } - requestAnimationFrame(() => { - ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); - decorateProjectMentions(); - }); - mentionStateRef.current = null; - setMentionState(null); - return; - } - - const replacement = mentionMarkdown(option); - - // Replace @query directly via DOM selection so the cursor naturally - // lands after the inserted text. Lexical picks up the change through - // its normal input-event handling. - const sel = window.getSelection(); - if (sel && state.textNode.isConnected) { - const range = document.createRange(); - range.setStart(state.textNode, state.atPos); - range.setEnd(state.textNode, state.endPos); - sel.removeAllRanges(); - sel.addRange(range); - document.execCommand("insertText", false, replacement); - - // After Lexical reconciles the DOM, the cursor position set by - // execCommand may be lost. Explicitly reposition it after the - // inserted mention text. - const cursorTarget = state.atPos + replacement.length; - requestAnimationFrame(() => { - const newSel = window.getSelection(); - if (!newSel) return; - // Try the original text node first (it may still be valid) - if (state.textNode.isConnected) { - const len = state.textNode.textContent?.length ?? 0; - if (cursorTarget <= len) { - const r = document.createRange(); - r.setStart(state.textNode, cursorTarget); - r.collapse(true); - newSel.removeAllRanges(); - newSel.addRange(r); - return; - } - } - // Fallback: search for the replacement in text nodes - const editable = containerRef.current?.querySelector('[contenteditable="true"]'); - if (!editable) return; - const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT); - let node: Text | null; - while ((node = walker.nextNode() as Text | null)) { - const text = node.textContent ?? ""; - const idx = text.indexOf(replacement); - if (idx !== -1) { - const pos = idx + replacement.length; - if (pos <= text.length) { - const r = document.createRange(); - r.setStart(node, pos); - r.collapse(true); - newSel.removeAllRanges(); - newSel.addRange(r); - return; - } - } - } - }); - } else { - // Fallback: full markdown replacement when DOM node is stale - const current = latestValueRef.current; - const next = applyMention(current, state.query, option); - if (next !== current) { - latestValueRef.current = next; - ref.current?.setMarkdown(next); - onChange(next); - } - requestAnimationFrame(() => { - ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); - }); + const current = latestValueRef.current; + const next = applyMention(current, state.query, option); + if (next !== current) { + latestValueRef.current = next; + ref.current?.setMarkdown(next); + onChange(next); } requestAnimationFrame(() => { - decorateProjectMentions(); + requestAnimationFrame(() => { + const editable = containerRef.current?.querySelector('[contenteditable="true"]'); + if (!(editable instanceof HTMLElement)) return; + decorateProjectMentions(); + editable.focus(); + + const mentionHref = option.kind === "project" && option.projectId + ? buildProjectMentionHref(option.projectId, option.projectColor ?? null) + : buildAgentMentionHref( + option.agentId ?? option.id.replace(/^agent:/, ""), + option.agentIcon ?? null, + ); + const matchingMentions = Array.from(editable.querySelectorAll("a")) + .filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement) + .filter((link) => { + const href = link.getAttribute("href") ?? ""; + return href === mentionHref && link.textContent === `@${option.name}`; + }); + const containerRect = containerRef.current?.getBoundingClientRect(); + const target = matchingMentions.sort((a, b) => { + const rectA = a.getBoundingClientRect(); + const rectB = b.getBoundingClientRect(); + const leftA = containerRect ? rectA.left - containerRect.left : rectA.left; + const topA = containerRect ? rectA.top - containerRect.top : rectA.top; + const leftB = containerRect ? rectB.left - containerRect.left : rectB.left; + const topB = containerRect ? rectB.top - containerRect.top : rectB.top; + const distA = Math.hypot(leftA - state.left, topA - state.top); + const distB = Math.hypot(leftB - state.left, topB - state.top); + return distA - distB; + })[0] ?? null; + if (!target) return; + + const selection = window.getSelection(); + if (!selection) return; + const range = document.createRange(); + const nextSibling = target.nextSibling; + if (nextSibling?.nodeType === Node.TEXT_NODE) { + const text = nextSibling.textContent ?? ""; + if (text.startsWith(" ")) { + range.setStart(nextSibling, 1); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return; + } + } + + range.setStartAfter(target); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }); }); mentionStateRef.current = null; @@ -616,7 +569,10 @@ export const MarkdownEditor = forwardRef style={{ backgroundColor: option.projectColor ?? "#64748b" }} /> ) : ( - @ + )} {option.name} {option.kind === "project" && option.projectId && ( diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 727a54e6..0a35bd34 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -376,6 +376,8 @@ export function NewIssueDialog() { id: `agent:${agent.id}`, name: agent.name, kind: "agent", + agentId: agent.id, + agentIcon: agent.icon, }); } for (const project of orderedProjects) { diff --git a/ui/src/index.css b/ui/src/index.css index c8eb8366..d6b1d359 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -339,6 +339,7 @@ margin-top: 1.1em; } +.paperclip-mdxeditor-content a.paperclip-mention-chip, .paperclip-mdxeditor-content a.paperclip-project-mention-chip { display: inline-flex; align-items: center; @@ -355,6 +356,35 @@ user-select: none; } +.paperclip-mdxeditor-content a.paperclip-mention-chip::before, +a.paperclip-mention-chip::before { + content: ""; + flex: none; +} + +.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="project"]::before, +a.paperclip-mention-chip[data-mention-kind="project"]::before { + width: 0.45rem; + height: 0.45rem; + border-radius: 999px; + background-color: var(--paperclip-mention-project-color, currentColor); +} + +.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="agent"]::before, +a.paperclip-mention-chip[data-mention-kind="agent"]::before { + width: 0.75rem; + height: 0.75rem; + background-color: currentColor; + -webkit-mask-image: var(--paperclip-mention-icon-mask); + mask-image: var(--paperclip-mention-icon-mask); + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-size: contain; +} + .paperclip-mdxeditor-content ul, .paperclip-mdxeditor-content ol { margin: 1.1em 0; @@ -700,6 +730,7 @@ } /* Project mention chips rendered inside MarkdownBody */ +a.paperclip-mention-chip, a.paperclip-project-mention-chip { display: inline-flex; align-items: center; diff --git a/ui/src/lib/agent-icons.ts b/ui/src/lib/agent-icons.ts new file mode 100644 index 00000000..f57b760a --- /dev/null +++ b/ui/src/lib/agent-icons.ts @@ -0,0 +1,98 @@ +import { + Atom, + Bot, + Brain, + Bug, + CircuitBoard, + Code, + Cog, + Cpu, + Crown, + Database, + Eye, + FileCode, + Fingerprint, + Flame, + Gem, + GitBranch, + Globe, + Hammer, + Heart, + Hexagon, + Lightbulb, + Lock, + Mail, + MessageSquare, + Microscope, + Package, + Pentagon, + Puzzle, + Radar, + Rocket, + Search, + Shield, + Sparkles, + Star, + Swords, + Target, + Telescope, + Terminal, + Wand2, + Wrench, + Zap, + type LucideIcon, +} from "lucide-react"; +import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared"; + +export const AGENT_ICONS: Record = { + bot: Bot, + cpu: Cpu, + brain: Brain, + zap: Zap, + rocket: Rocket, + code: Code, + terminal: Terminal, + shield: Shield, + eye: Eye, + search: Search, + wrench: Wrench, + hammer: Hammer, + lightbulb: Lightbulb, + sparkles: Sparkles, + star: Star, + heart: Heart, + flame: Flame, + bug: Bug, + cog: Cog, + database: Database, + globe: Globe, + lock: Lock, + mail: Mail, + "message-square": MessageSquare, + "file-code": FileCode, + "git-branch": GitBranch, + package: Package, + puzzle: Puzzle, + target: Target, + wand: Wand2, + atom: Atom, + "circuit-board": CircuitBoard, + radar: Radar, + swords: Swords, + telescope: Telescope, + microscope: Microscope, + crown: Crown, + gem: Gem, + hexagon: Hexagon, + pentagon: Pentagon, + fingerprint: Fingerprint, +}; + +const DEFAULT_ICON: AgentIconName = "bot"; + +export function getAgentIcon(iconName: string | null | undefined): LucideIcon { + if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) { + return AGENT_ICONS[iconName as AgentIconName]; + } + return AGENT_ICONS[DEFAULT_ICON]; +} diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts new file mode 100644 index 00000000..d082cd7f --- /dev/null +++ b/ui/src/lib/mention-chips.ts @@ -0,0 +1,160 @@ +import type { CSSProperties } from "react"; +import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; +import { getAgentIcon } from "./agent-icons"; + +export type ParsedMentionChip = + | { + kind: "agent"; + agentId: string; + icon: string | null; + } + | { + kind: "project"; + projectId: string; + color: string | null; + }; + +const iconMaskCache = new Map(); + +export function parseMentionChipHref(href: string): ParsedMentionChip | null { + const agent = parseAgentMentionHref(href); + if (agent) { + return { + kind: "agent", + agentId: agent.agentId, + icon: agent.icon, + }; + } + + const project = parseProjectMentionHref(href); + if (project) { + return { + kind: "project", + projectId: project.projectId, + color: project.color, + }; + } + + return null; +} + +export function mentionChipInlineStyle(mention: ParsedMentionChip): CSSProperties | undefined { + const style: CSSProperties & Record = {}; + + if (mention.kind === "project" && mention.color) { + const projectStyle = projectMentionColors(mention.color); + Object.assign(style, projectStyle); + style["--paperclip-mention-project-color"] = mention.color; + } + + if (mention.kind === "agent") { + const iconMask = buildAgentIconMask(mention.icon); + if (iconMask) { + style["--paperclip-mention-icon-mask"] = iconMask; + } + } + + return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined; +} + +export function applyMentionChipDecoration(element: HTMLElement, mention: ParsedMentionChip) { + clearMentionChipDecoration(element); + element.dataset.mentionKind = mention.kind; + element.setAttribute("contenteditable", "false"); + element.classList.add("paperclip-mention-chip", `paperclip-mention-chip--${mention.kind}`); + if (mention.kind === "project") { + element.classList.add("paperclip-project-mention-chip"); + } + + const style = mentionChipInlineStyle(mention); + if (!style) return; + for (const [key, value] of Object.entries(style)) { + if (typeof value === "string") { + if (key.startsWith("--")) { + element.style.setProperty(key, value); + } else { + (element.style as CSSStyleDeclaration & Record)[key] = value; + } + } + } +} + +export function clearMentionChipDecoration(element: HTMLElement) { + delete element.dataset.mentionKind; + element.classList.remove( + "paperclip-mention-chip", + "paperclip-mention-chip--agent", + "paperclip-mention-chip--project", + "paperclip-project-mention-chip", + ); + element.removeAttribute("contenteditable"); + element.style.removeProperty("border-color"); + element.style.removeProperty("background-color"); + element.style.removeProperty("color"); + element.style.removeProperty("--paperclip-mention-project-color"); + element.style.removeProperty("--paperclip-mention-icon-mask"); +} + +function projectMentionColors(color: string): Pick { + const rgb = hexToRgb(color); + if (!rgb) return {}; + const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + return { + borderColor: color, + backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, + color: luminance > 0.55 ? "#111827" : "#f8fafc", + }; +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) return null; + const value = match[1]; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function buildAgentIconMask(iconName: string | null): string | null { + const cacheKey = iconName ?? "__default__"; + const cached = iconMaskCache.get(cacheKey); + if (cached) return cached; + + const Icon = getAgentIcon(iconName); + const rendered = ( + Icon as unknown as { + render: ( + props: Record, + ref: unknown, + ) => { props?: { iconNode?: Array<[string, Record]> } }; + } + ).render({ size: 12, strokeWidth: 2 }, null); + const iconNode = rendered?.props?.iconNode; + if (!Array.isArray(iconNode) || iconNode.length === 0) return null; + + const body = iconNode.map(([tag, attrs]) => { + const attrString = Object.entries(attrs) + .filter(([key]) => key !== "key") + .map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`) + .join(" "); + return `<${tag}${attrString ? ` ${attrString}` : ""}>`; + }).join(""); + + const svg = + `${body}`; + const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; + iconMaskCache.set(cacheKey, url); + return url; +} + +function escapeAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 2c6ae104..ecdba988 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -341,6 +341,8 @@ export function IssueDetail() { id: `agent:${agent.id}`, name: agent.name, kind: "agent", + agentId: agent.id, + agentIcon: agent.icon, }); } for (const project of orderedProjects) { From 49ace2faf9d47a4eb6a780e3aa56f1b739eea5c6 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 14:59:20 -0500 Subject: [PATCH 06/21] Allow custom markdown mention links in editor Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 0f6bd402..afbd29fc 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -68,6 +68,12 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function isSafeMarkdownLinkUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return true; + return !/^(javascript|data|vbscript):/i.test(trimmed); +} + /* ---- Mention detection helpers ---- */ interface MentionState { @@ -269,7 +275,7 @@ export const MarkdownEditor = forwardRef listsPlugin(), quotePlugin(), tablePlugin(), - linkPlugin(), + linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }), linkDialogPlugin(), thematicBreakPlugin(), codeBlockPlugin({ From 0e8e162cd52fcae2d08d15539e1c583a512ecdb5 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 16:39:12 -0500 Subject: [PATCH 07/21] Fix mention pills by allowing custom URL schemes in Lexical LinkNode The previous fix (validateUrl on linkPlugin) only affected the link dialog, not the markdown-to-Lexical import path. Lexical's LinkNode.sanitizeUrl() converts agent:// and project:// URLs to about:blank because they aren't in its allowlist. Override the prototype method to preserve these schemes so mention chips render correctly. Co-Authored-By: Paperclip --- pnpm-lock.yaml | 9 ++++++--- ui/package.json | 3 ++- ui/src/components/MarkdownEditor.tsx | 11 +++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56a8a46a..cdc34c26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0) cli: dependencies: @@ -583,6 +583,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.4) + '@lexical/link': + specifier: 0.35.0 + version: 0.35.0 '@mdxeditor/editor': specifier: ^3.52.4 version: 3.52.4(@codemirror/language@6.12.1)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29) @@ -685,7 +688,7 @@ importers: version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0) packages: @@ -12164,7 +12167,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 diff --git a/ui/package.json b/ui/package.json index 5ce15553..112fa86e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,14 +13,15 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lexical/link": "0.35.0", "@mdxeditor/editor": "^3.52.4", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", - "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index afbd29fc..84218a09 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -26,11 +26,22 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; +import { LinkNode } from "@lexical/link"; import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { cn } from "../lib/utils"; +/* ---- Allow custom mention URL schemes in Lexical's LinkNode ---- */ +// Lexical only allows http(s)/mailto/sms/tel by default, converting +// everything else to about:blank. We need agent:// and project:// +// to survive the markdown→Lexical import so mention chips render. +const _origSanitizeUrl = LinkNode.prototype.sanitizeUrl; +LinkNode.prototype.sanitizeUrl = function sanitizeUrl(url: string): string { + if (/^(agent|project):\/\//.test(url)) return url; + return _origSanitizeUrl.call(this, url); +}; + /* ---- Mention types ---- */ export interface MentionOption { From db42adf1bf33c2f27af8d83ef3c89110aedeb47e Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 22 Mar 2026 06:33:49 -0500 Subject: [PATCH 08/21] Make agent instructions tab responsive on mobile On mobile, the two-panel file browser + editor layout now stacks vertically with a toggleable file panel. The draggable separator is hidden, and selecting a file auto-closes the panel to maximize editor space. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/AgentDetail.tsx | 90 +++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2c5297d0..ebb6a667 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -69,6 +69,7 @@ import { ChevronDown, ArrowLeft, HelpCircle, + FolderOpen, } from "lucide-react"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -1589,7 +1590,9 @@ function PromptsTab({ }) { const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); + const { isMobile } = useSidebar(); const [selectedFile, setSelectedFile] = useState("AGENTS.md"); + const [showFilePanel, setShowFilePanel] = useState(false); const [draft, setDraft] = useState(null); const [bundleDraft, setBundleDraft] = useState<{ mode: "managed" | "external"; @@ -2042,21 +2045,38 @@ function PromptsTab({ -
-
+
+

Files

- {!showNewFileInput && ( - - )} +
+ {!showNewFileInput && ( + + )} + {isMobile && ( + + )} +
{showNewFileInput && (
@@ -2121,6 +2141,7 @@ function PromptsTab({ onSelectFile={(filePath) => { setSelectedFile(filePath); if (!fileOptions.includes(filePath)) setDraft(""); + if (isMobile) setShowFilePanel(false); }} onToggleCheck={() => {}} showCheckboxes={false} @@ -2151,22 +2172,37 @@ function PromptsTab({
{/* Draggable separator */} -
+ {!isMobile && ( +
+ )} -
+
-
-

{selectedOrEntryFile}

-

- {selectedFileExists - ? selectedFileSummary?.deprecated - ? "Deprecated virtual file" - : `${selectedFileDetail?.language ?? "text"} file` - : "New file in this bundle"} -

+
+ {isMobile && ( + + )} +
+

{selectedOrEntryFile}

+

+ {selectedFileExists + ? selectedFileSummary?.deprecated + ? "Deprecated virtual file" + : `${selectedFileDetail?.language ?? "text"} file` + : "New file in this bundle"} +

+
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
) : ( -
-

Documents

-
+
+

Documents

+
{extraActions} -
@@ -634,29 +636,29 @@ export function IssueDocumentsSection({ >
-
+
- + {doc.key} rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
{showTitle &&

{doc.title}

}
-
+
); From d73c8df8952e69c0a81fa50ef1c4b954a07785a7 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 07:48:50 -0500 Subject: [PATCH 15/21] fix: improve pill contrast by using WCAG contrast ratios on composited backgrounds Pills with semi-transparent backgrounds were using raw color luminance to pick text color, ignoring the page background showing through. This caused unreadable text on dark themes for mid-luminance colors like orange. Now composites the rgba background over the actual page bg (dark/light) before computing WCAG contrast ratios, and centralizes the logic in a shared color-contrast utility. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssueProperties.tsx | 3 +- ui/src/components/IssuesList.tsx | 3 +- ui/src/components/NewIssueDialog.tsx | 14 +--- ui/src/lib/color-contrast.ts | 107 ++++++++++++++++++++++++++ ui/src/lib/mention-chips.ts | 15 +--- ui/src/pages/IssueDetail.tsx | 3 +- 6 files changed, 118 insertions(+), 27 deletions(-) create mode 100644 ui/src/lib/color-contrast.ts diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 053e8e42..3b8bb25e 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; +import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; import type { Issue } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -329,7 +330,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp style={{ borderColor: label.color, backgroundColor: `${label.color}22`, - color: label.color, + color: pickTextColorForPillBg(label.color, 0.13), }} > {label.name} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index c7f75f1e..b5c49ebc 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; +import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; @@ -719,7 +720,7 @@ export function IssuesList({ className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium" style={{ borderColor: label.color, - color: label.color, + color: pickTextColorForPillBg(label.color, 0.12), backgroundColor: `${label.color}1f`, }} > diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 6b8519cd..e20b0db1 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { pickTextColorForSolidBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { executionWorkspacesApi } from "../api/execution-workspaces"; @@ -56,15 +57,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel const DRAFT_KEY = "paperclip:issue-draft"; const DEBOUNCE_MS = 800; -/** Return black or white hex based on background luminance (WCAG perceptual weights). */ -function getContrastTextColor(hexColor: string): string { - const hex = hexColor.replace("#", ""); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > 0.5 ? "#000000" : "#ffffff"; -} interface IssueDraft { title: string; @@ -915,7 +907,7 @@ export function NewIssueDialog() { dialogCompany?.brandColor ? { backgroundColor: dialogCompany.brandColor, - color: getContrastTextColor(dialogCompany.brandColor), + color: pickTextColorForSolidBg(dialogCompany.brandColor), } : undefined } @@ -945,7 +937,7 @@ export function NewIssueDialog() { c.brandColor ? { backgroundColor: c.brandColor, - color: getContrastTextColor(c.brandColor), + color: pickTextColorForSolidBg(c.brandColor), } : undefined } diff --git a/ui/src/lib/color-contrast.ts b/ui/src/lib/color-contrast.ts new file mode 100644 index 00000000..70b2296a --- /dev/null +++ b/ui/src/lib/color-contrast.ts @@ -0,0 +1,107 @@ +/** + * Shared color-contrast utilities for pill / badge / chip components. + * + * Uses WCAG 2.1 relative-luminance contrast ratios so text is always + * readable, even on semi-transparent backgrounds composited over dark or + * light page backgrounds. + */ + +const DARK_BG = { r: 24, g: 24, b: 27 }; // zinc-900 (#18181b) +const LIGHT_BG = { r: 255, g: 255, b: 255 }; // white + +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const match = /^#?([0-9a-f]{3,6})$/i.exec(hex.trim()); + if (!match) return null; + let value = match[1]; + if (value.length === 3) { + value = value + .split("") + .map((c) => `${c}${c}`) + .join(""); + } + if (value.length !== 6) return null; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 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(r: number, g: number, b: number): number { + return ( + 0.2126 * relativeLuminanceChannel(r) + + 0.7152 * relativeLuminanceChannel(g) + + 0.0722 * relativeLuminanceChannel(b) + ); +} + +function contrastRatio(l1: number, l2: number): number { + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +function isDarkMode(): boolean { + if (typeof document === "undefined") return true; + return document.documentElement.classList.contains("dark"); +} + +/** + * Composite a foreground RGB at the given alpha over a background RGB. + */ +function composite( + fg: { r: number; g: number; b: number }, + bg: { r: number; g: number; b: number }, + alpha: number, +): { r: number; g: number; b: number } { + return { + r: Math.round(alpha * fg.r + (1 - alpha) * bg.r), + g: Math.round(alpha * fg.g + (1 - alpha) * bg.g), + b: Math.round(alpha * fg.b + (1 - alpha) * bg.b), + }; +} + +const TEXT_LIGHT = "#f8fafc"; +const TEXT_DARK = "#111827"; + +/** + * Pick a readable text color for a solid background. + * Uses WCAG contrast ratios to choose between light and dark text. + */ +export function pickTextColorForSolidBg(hexColor: string): string { + const rgb = hexToRgb(hexColor); + if (!rgb) return TEXT_LIGHT; + const bgLum = relativeLuminance(rgb.r, rgb.g, rgb.b); + const whiteLum = relativeLuminance(248, 250, 252); + const blackLum = relativeLuminance(17, 24, 39); + return contrastRatio(bgLum, whiteLum) >= contrastRatio(bgLum, blackLum) + ? TEXT_LIGHT + : TEXT_DARK; +} + +/** + * Pick a readable text color for a semi-transparent pill background. + * + * Composites `rgba(hexColor, alpha)` over the current page background + * (dark or light mode) and then picks the text color with better + * WCAG contrast ratio. + */ +export function pickTextColorForPillBg(hexColor: string, alpha = 0.22): string { + const fg = hexToRgb(hexColor); + if (!fg) return TEXT_LIGHT; + const pageBg = isDarkMode() ? DARK_BG : LIGHT_BG; + const effectiveBg = composite(fg, pageBg, alpha); + const bgLum = relativeLuminance(effectiveBg.r, effectiveBg.g, effectiveBg.b); + const whiteLum = relativeLuminance(248, 250, 252); + const blackLum = relativeLuminance(17, 24, 39); + return contrastRatio(bgLum, whiteLum) >= contrastRatio(bgLum, blackLum) + ? TEXT_LIGHT + : TEXT_DARK; +} diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index d082cd7f..d00185a8 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -1,6 +1,7 @@ import type { CSSProperties } from "react"; import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; import { getAgentIcon } from "./agent-icons"; +import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; export type ParsedMentionChip = | { @@ -98,22 +99,10 @@ export function clearMentionChipDecoration(element: HTMLElement) { function projectMentionColors(color: string): Pick { const rgb = hexToRgb(color); if (!rgb) return {}; - const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; return { borderColor: color, backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, - color: luminance > 0.55 ? "#111827" : "#f8fafc", - }; -} - -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); - if (!match) return null; - const value = match[1]; - return { - r: parseInt(value.slice(0, 2), 16), - g: parseInt(value.slice(2, 4), 16), - b: parseInt(value.slice(4, 6), 16), + color: pickTextColorForPillBg(color), }; } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index db99339b..ed23b055 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; +import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; @@ -767,7 +768,7 @@ export function IssueDetail() { className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium" style={{ borderColor: label.color, - color: label.color, + color: pickTextColorForPillBg(label.color, 0.12), backgroundColor: `${label.color}1f`, }} > From c3f4e18a5ee9e1069f66c4c37f01489705d40662 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 17:02:43 -0500 Subject: [PATCH 16/21] Keep sidebar ordering with portability branch Co-Authored-By: Paperclip --- ui/src/components/SidebarAgents.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 88433fd8..0a7e8c18 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -28,10 +28,6 @@ function sortByHierarchy(agents: Agent[]): Agent[] { list.push(a); childrenOf.set(parent, list); } - // Sort children at each level alphabetically by name - for (const [, list] of childrenOf) { - list.sort((a, b) => a.name.localeCompare(b.name)); - } const sorted: Agent[] = []; const queue = childrenOf.get(null) ?? []; while (queue.length > 0) { From 6960ab11067637cb757f3f622827ac4a61959a1c Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 17:16:10 -0500 Subject: [PATCH 17/21] Address Greptile review on UI polish PR Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.tsx | 51 +++++++++++++++++++++----- ui/src/context/LiveUpdatesProvider.tsx | 9 ++++- ui/src/lib/mention-chips.ts | 10 ++--- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 4ff06da2..b30c3edf 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -26,22 +26,52 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; -import { LinkNode } from "@lexical/link"; +import { LinkNode, type LinkAttributes } from "@lexical/link"; import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { mentionDeletionPlugin } from "../lib/mention-deletion"; import { cn } from "../lib/utils"; -/* ---- Allow custom mention URL schemes in Lexical's LinkNode ---- */ -// Lexical only allows http(s)/mailto/sms/tel by default, converting -// everything else to about:blank. We need agent:// and project:// -// to survive the markdown→Lexical import so mention chips render. -const _origSanitizeUrl = LinkNode.prototype.sanitizeUrl; -LinkNode.prototype.sanitizeUrl = function sanitizeUrl(url: string): string { - if (/^(agent|project):\/\//.test(url)) return url; - return _origSanitizeUrl.call(this, url); -}; +const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//; + +class MentionAwareLinkNode extends LinkNode { + static clone(node: MentionAwareLinkNode): MentionAwareLinkNode { + return new MentionAwareLinkNode( + node.getURL(), + { + rel: node.getRel(), + target: node.getTarget(), + title: node.getTitle(), + }, + node.getKey(), + ); + } + + constructor(url?: string, attributes?: LinkAttributes, key?: string) { + super(url, attributes, key); + } + + sanitizeUrl(url: string): string { + if (CUSTOM_MENTION_URL_RE.test(url)) return url; + return super.sanitizeUrl(url); + } +} + +const mentionAwareLinkNodeReplacement = { + replace: LinkNode, + with: (node: LinkNode) => + new MentionAwareLinkNode( + node.getURL(), + { + rel: node.getRel(), + target: node.getTarget(), + title: node.getTitle(), + }, + node.getKey(), + ), + withKlass: MentionAwareLinkNode, +} as const; /* ---- Mention types ---- */ @@ -560,6 +590,7 @@ export const MarkdownEditor = forwardRef "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item", contentClassName, )} + additionalLexicalNodes={[mentionAwareLinkNodeReplacement]} plugins={plugins} /> diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 6acb9af8..86746dcc 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -664,6 +664,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const { pushToast } = useToast(); const location = useLocation(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); + const pathnameRef = useRef(location.pathname); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -671,6 +672,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + useEffect(() => { + pathnameRef.current = location.pathname; + }, [location.pathname]); + useEffect(() => { if (!selectedCompanyId) return; @@ -715,7 +720,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, location.pathname, parsed, pushToast, gateRef.current, { + handleLiveEvent(queryClient, selectedCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, { userId: currentUserId, agentId: null, }); @@ -747,7 +752,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { socket.close(1000, "provider_unmount"); } }; - }, [queryClient, selectedCompanyId, pushToast, currentUserId, location.pathname]); + }, [queryClient, selectedCompanyId, pushToast, currentUserId]); return <>{children}; } diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index d00185a8..d951d0ce 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -112,15 +112,11 @@ function buildAgentIconMask(iconName: string | null): string | null { if (cached) return cached; const Icon = getAgentIcon(iconName); - const rendered = ( + const iconNode = ( Icon as unknown as { - render: ( - props: Record, - ref: unknown, - ) => { props?: { iconNode?: Array<[string, Record]> } }; + iconNode?: Array<[string, Record]>; } - ).render({ size: 12, strokeWidth: 2 }, null); - const iconNode = rendered?.props?.iconNode; + ).iconNode; if (!Array.isArray(iconNode) || iconNode.length === 0) return null; const body = iconNode.map(([tag, attrs]) => { From 7576c5ecbc9b9f078e1625ddd71dafcb89e7e9f7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:00:34 -0500 Subject: [PATCH 18/21] Update ui/src/pages/Auth.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/src/pages/Auth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index 048d10c5..97e95d58 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -90,7 +90,7 @@ export function AuthPage() { { event.preventDefault(); if (mutation.isPending) return; From 2cc2d4420d9d2a21dca95457a90bd751d27ba2f8 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 19:01:39 -0500 Subject: [PATCH 19/21] Remove lockfile changes from UI polish PR Co-Authored-By: Paperclip --- pnpm-lock.yaml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdc34c26..56a8a46a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) cli: dependencies: @@ -583,9 +583,6 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.4) - '@lexical/link': - specifier: 0.35.0 - version: 0.35.0 '@mdxeditor/editor': specifier: ^3.52.4 version: 3.52.4(@codemirror/language@6.12.1)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29) @@ -688,7 +685,7 @@ importers: version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) packages: @@ -12167,7 +12164,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 From 85d2c54d5383fc78c69cedbfd37f245645a6785e Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 19:23:10 -0500 Subject: [PATCH 20/21] fix(ci): refresh lockfile in PR jobs --- .github/workflows/pr.yml | 20 ++++++++++++++++++++ ui/src/lib/mention-chips.ts | 32 +++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8ec14f0d..b19de9dd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -56,6 +56,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -68,6 +70,14 @@ jobs: node-version: 24 cache: pnpm + - name: Refresh lockfile when manifests change + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$' + if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then + pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + fi + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -94,6 +104,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -106,6 +118,14 @@ jobs: node-version: 24 cache: pnpm + - name: Refresh lockfile when manifests change + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$' + if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then + pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + fi + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index d951d0ce..fe043100 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -112,11 +112,7 @@ function buildAgentIconMask(iconName: string | null): string | null { if (cached) return cached; const Icon = getAgentIcon(iconName); - const iconNode = ( - Icon as unknown as { - iconNode?: Array<[string, Record]>; - } - ).iconNode; + const iconNode = resolveLucideIconNode(Icon); if (!Array.isArray(iconNode) || iconNode.length === 0) return null; const body = iconNode.map(([tag, attrs]) => { @@ -136,6 +132,32 @@ function buildAgentIconMask(iconName: string | null): string | null { return url; } +function resolveLucideIconNode( + icon: unknown, +): Array<[string, Record]> | null { + const staticIconNode = ( + icon as { + iconNode?: Array<[string, Record]>; + } + ).iconNode; + if (Array.isArray(staticIconNode) && staticIconNode.length > 0) { + return staticIconNode; + } + + const render = ( + icon as { + render?: (props: Record, ref: unknown) => { + props?: { iconNode?: Array<[string, Record]> }; + } | null; + } + ).render; + const rendered = typeof render === "function" ? render({}, null) : null; + const renderedIconNode = rendered?.props?.iconNode; + return Array.isArray(renderedIconNode) && renderedIconNode.length > 0 + ? renderedIconNode + : null; +} + function escapeAttribute(value: string): string { return value .replaceAll("&", "&") From 22067c7d1db980ecafb20142f8e3c9057121c29c Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 19:26:33 -0500 Subject: [PATCH 21/21] revert: drop PR workflow lockfile refresh --- .github/workflows/pr.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b19de9dd..8ec14f0d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -56,8 +56,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -70,14 +68,6 @@ jobs: node-version: 24 cache: pnpm - - name: Refresh lockfile when manifests change - run: | - changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" - manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$' - if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then - pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile - fi - - name: Install dependencies run: pnpm install --frozen-lockfile @@ -104,8 +94,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -118,14 +106,6 @@ jobs: node-version: 24 cache: pnpm - - name: Refresh lockfile when manifests change - run: | - changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" - manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$' - if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then - pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile - fi - - name: Install dependencies run: pnpm install --frozen-lockfile