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/package.json b/ui/package.json index 5ce15553..a02ddb12 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,14 +13,16 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lexical/link": "0.35.0", + "lexical": "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/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/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 0f112062..f61ddd75 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -519,21 +519,23 @@ export function IssueDocumentsSection({ return (
{isEmpty && !draft?.isNew ? ( -
+
{extraActions} -
) : ( -
-

Documents

-
+
+

Documents

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

{doc.title}

}
-
+
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 29a57a3a..b30c3edf 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,15 +26,61 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; -import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; +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"; +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 ---- */ export interface MentionOption { id: string; name: string; kind?: "agent" | "project"; + agentId?: string; + agentIcon?: string | null; projectId?: string; projectColor?: string | null; } @@ -65,6 +110,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 { @@ -154,7 +205,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 +218,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 +248,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; @@ -286,8 +317,9 @@ export const MarkdownEditor = forwardRef listsPlugin(), quotePlugin(), tablePlugin(), - linkPlugin(), + linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }), linkDialogPlugin(), + mentionDeletionPlugin(), thematicBreakPlugin(), codeBlockPlugin({ defaultCodeBlockLanguage: "txt", @@ -315,31 +347,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 +424,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; @@ -588,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} /> @@ -616,7 +619,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..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; @@ -376,6 +368,8 @@ export function NewIssueDialog() { id: `agent:${agent.id}`, name: agent.name, kind: "agent", + agentId: agent.id, + agentIcon: agent.icon, }); } for (const project of orderedProjects) { @@ -913,7 +907,7 @@ export function NewIssueDialog() { dialogCompany?.brandColor ? { backgroundColor: dialogCompany.brandColor, - color: getContrastTextColor(dialogCompany.brandColor), + color: pickTextColorForSolidBg(dialogCompany.brandColor), } : undefined } @@ -943,7 +937,7 @@ export function NewIssueDialog() { c.brandColor ? { backgroundColor: c.brandColor, - color: getContrastTextColor(c.brandColor), + color: pickTextColorForSolidBg(c.brandColor), } : undefined } @@ -1211,6 +1205,7 @@ export function NewIssueDialog() {
Enable Chrome (--chrome)
- )} +
+ {!showNewFileInput && ( + + )} + {isMobile && ( + + )} +
{showNewFileInput && (
@@ -2121,6 +2143,7 @@ function PromptsTab({ onSelectFile={(filePath) => { setSelectedFile(filePath); if (!fileOptions.includes(filePath)) setDraft(""); + if (isMobile) setShowFilePanel(false); }} onToggleCheck={() => {}} showCheckboxes={false} @@ -2151,22 +2174,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 && ( ); @@ -760,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`, }} > 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={ diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index f357878d..387e31f7 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -647,6 +647,7 @@ export function RoutineDetail() {