Merge pull request #1653 from paperclipai/pr/pap-795-ui-polish
fix(ui): polish issue and agent surfaces
This commit is contained in:
commit
fa084e1a16
39 changed files with 1307 additions and 359 deletions
|
|
@ -535,10 +535,15 @@ export { API_PREFIX, API } from "./api.js";
|
||||||
export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js";
|
export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js";
|
||||||
export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js";
|
export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js";
|
||||||
export {
|
export {
|
||||||
|
AGENT_MENTION_SCHEME,
|
||||||
PROJECT_MENTION_SCHEME,
|
PROJECT_MENTION_SCHEME,
|
||||||
|
buildAgentMentionHref,
|
||||||
buildProjectMentionHref,
|
buildProjectMentionHref,
|
||||||
|
extractAgentMentionIds,
|
||||||
|
parseAgentMentionHref,
|
||||||
parseProjectMentionHref,
|
parseProjectMentionHref,
|
||||||
extractProjectMentionIds,
|
extractProjectMentionIds,
|
||||||
|
type ParsedAgentMention,
|
||||||
type ParsedProjectMention,
|
type ParsedProjectMention,
|
||||||
} from "./project-mentions.js";
|
} from "./project-mentions.js";
|
||||||
|
|
||||||
|
|
|
||||||
29
packages/shared/src/project-mentions.test.ts
Normal file
29
packages/shared/src/project-mentions.test.ts
Normal file
|
|
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
export const PROJECT_MENTION_SCHEME = "project://";
|
export const PROJECT_MENTION_SCHEME = "project://";
|
||||||
|
export const AGENT_MENTION_SCHEME = "agent://";
|
||||||
|
|
||||||
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
|
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
|
||||||
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/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_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
|
||||||
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
|
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
|
||||||
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
|
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 {
|
export interface ParsedProjectMention {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedAgentMention {
|
||||||
|
agentId: string;
|
||||||
|
icon: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHexColor(input: string | null | undefined): string | null {
|
function normalizeHexColor(input: string | null | undefined): string | null {
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
const trimmed = input.trim();
|
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[] {
|
export function extractProjectMentionIds(markdown: string): string[] {
|
||||||
if (!markdown) return [];
|
if (!markdown) return [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
@ -76,3 +114,22 @@ export function extractProjectMentionIds(markdown: string): string[] {
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractAgentMentionIds(markdown: string): string[] {
|
||||||
|
if (!markdown) return [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import { extractProjectMentionIds } from "@paperclipai/shared";
|
import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared";
|
||||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
import {
|
import {
|
||||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||||
|
|
@ -1462,10 +1462,19 @@ export function issueService(db: Db) {
|
||||||
const tokens = new Set<string>();
|
const tokens = new Set<string>();
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(body)) !== null) tokens.add(m[1].toLowerCase());
|
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 })
|
const rows = await db.select({ id: agents.id, name: agents.name })
|
||||||
.from(agents).where(eq(agents.companyId, companyId));
|
.from(agents).where(eq(agents.companyId, companyId));
|
||||||
return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id);
|
const resolved = new Set<string>(explicitAgentMentionIds);
|
||||||
|
for (const agent of rows) {
|
||||||
|
if (tokens.has(agent.name.toLowerCase())) {
|
||||||
|
resolved.add(agent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...resolved];
|
||||||
},
|
},
|
||||||
|
|
||||||
findMentionedProjectIds: async (issueId: string) => {
|
findMentionedProjectIds: async (issueId: string) => {
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,16 @@
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@lexical/link": "0.35.0",
|
||||||
|
"lexical": "0.35.0",
|
||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,5 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import {
|
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,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared";
|
import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared";
|
||||||
|
|
@ -51,60 +10,10 @@ import {
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AGENT_ICONS, getAgentIcon } from "../lib/agent-icons";
|
||||||
export const AGENT_ICONS: Record<AgentIconName, LucideIcon> = {
|
|
||||||
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";
|
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 {
|
interface AgentIconProps {
|
||||||
icon: string | null | undefined;
|
icon: string | null | undefined;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
|
||||||
|
|
@ -311,8 +311,11 @@ export function CommentThread({
|
||||||
return Array.from(agentMap.values())
|
return Array.from(agentMap.values())
|
||||||
.filter((a) => a.status !== "terminated")
|
.filter((a) => a.status !== "terminated")
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
id: a.id,
|
id: `agent:${a.id}`,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
kind: "agent",
|
||||||
|
agentId: a.id,
|
||||||
|
agentIcon: a.icon,
|
||||||
}));
|
}));
|
||||||
}, [agentMap, providedMentions]);
|
}, [agentMap, providedMentions]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -519,21 +519,23 @@ export function IssueDocumentsSection({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{isEmpty && !draft?.isNew ? (
|
{isEmpty && !draft?.isNew ? (
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2 min-w-0">
|
||||||
{extraActions}
|
{extraActions}
|
||||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
New document
|
<span className="hidden sm:inline">New document</span>
|
||||||
|
<span className="sm:hidden">New</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
<h3 className="text-sm font-medium text-muted-foreground shrink-0">Documents</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{extraActions}
|
{extraActions}
|
||||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
New document
|
<span className="hidden sm:inline">New document</span>
|
||||||
|
<span className="sm:hidden">New</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -634,29 +636,29 @@ export function IssueDocumentsSection({
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
onClick={() => toggleFoldedDocument(doc.key)}
|
onClick={() => toggleFoldedDocument(doc.key)}
|
||||||
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
|
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
|
||||||
aria-expanded={!isFolded}
|
aria-expanded={!isFolded}
|
||||||
>
|
>
|
||||||
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{doc.key}
|
{doc.key}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={`#document-${encodeURIComponent(doc.key)}`}
|
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||||
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
className="truncate text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -329,7 +330,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
style={{
|
style={{
|
||||||
borderColor: label.color,
|
borderColor: label.color,
|
||||||
backgroundColor: `${label.color}22`,
|
backgroundColor: `${label.color}22`,
|
||||||
color: label.color,
|
color: pickTextColorForPillBg(label.color, 0.13),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label.name}
|
{label.name}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import type { ReactNode } from "react";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
type UnreadState = "hidden" | "visible" | "fading";
|
type UnreadState = "hidden" | "visible" | "fading";
|
||||||
|
|
@ -61,9 +60,6 @@ export function IssueRow({
|
||||||
) : null}
|
) : null}
|
||||||
{desktopMetaLeading ?? (
|
{desktopMetaLeading ?? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden sm:inline-flex">
|
|
||||||
<PriorityIcon priority={issue.priority} />
|
|
||||||
</span>
|
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
<StatusIcon status={issue.status} />
|
<StatusIcon status={issue.status} />
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
|
@ -680,9 +681,6 @@ export function IssuesList({
|
||||||
)}
|
)}
|
||||||
desktopMetaLeading={(
|
desktopMetaLeading={(
|
||||||
<>
|
<>
|
||||||
<span className="hidden sm:inline-flex">
|
|
||||||
<PriorityIcon priority={issue.priority} />
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
className="hidden shrink-0 sm:inline-flex"
|
className="hidden shrink-0 sm:inline-flex"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -722,7 +720,7 @@ export function IssuesList({
|
||||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
borderColor: label.color,
|
borderColor: label.color,
|
||||||
color: label.color,
|
color: pickTextColorForPillBg(label.color, 0.12),
|
||||||
backgroundColor: `${label.color}1f`,
|
backgroundColor: `${label.color}1f`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||||
import { ThemeProvider } from "../context/ThemeContext";
|
import { ThemeProvider } from "../context/ThemeContext";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
|
|
||||||
|
|
@ -28,4 +29,21 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
||||||
expect(html).toContain('alt="Org chart"');
|
expect(html).toContain('alt="Org chart"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders agent and project mentions as chips", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ThemeProvider>
|
||||||
|
<MarkdownBody>
|
||||||
|
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`}
|
||||||
|
</MarkdownBody>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 Markdown, { type Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { parseProjectMentionHref } from "@paperclipai/shared";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
||||||
|
|
||||||
interface MarkdownBodyProps {
|
interface MarkdownBodyProps {
|
||||||
children: string;
|
children: string;
|
||||||
|
|
@ -36,29 +36,6 @@ function extractMermaidSource(children: ReactNode): string | null {
|
||||||
return flattenText(childProps.children).replace(/\n$/, "");
|
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 }) {
|
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
|
||||||
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
const [svg, setSvg] = useState<string | null>(null);
|
const [svg, setSvg] = useState<string | null>(null);
|
||||||
|
|
@ -125,16 +102,23 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
||||||
return <pre {...preProps}>{preChildren}</pre>;
|
return <pre {...preProps}>{preChildren}</pre>;
|
||||||
},
|
},
|
||||||
a: ({ href, children: linkChildren }) => {
|
a: ({ href, children: linkChildren }) => {
|
||||||
const parsed = href ? parseProjectMentionHref(href) : null;
|
const parsed = href ? parseMentionChipHref(href) : null;
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const label = linkChildren;
|
const targetHref = parsed.kind === "project"
|
||||||
|
? `/projects/${parsed.projectId}`
|
||||||
|
: `/agents/${parsed.agentId}`;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={`/projects/${parsed.projectId}`}
|
href={targetHref}
|
||||||
className="paperclip-project-mention-chip"
|
className={cn(
|
||||||
style={mentionChipStyle(parsed.color)}
|
"paperclip-mention-chip",
|
||||||
|
`paperclip-mention-chip--${parsed.kind}`,
|
||||||
|
parsed.kind === "project" && "paperclip-project-mention-chip",
|
||||||
|
)}
|
||||||
|
data-mention-kind={parsed.kind}
|
||||||
|
style={mentionChipInlineStyle(parsed)}
|
||||||
>
|
>
|
||||||
{label}
|
{linkChildren}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +144,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Markdown remarkPlugins={[remarkGfm]} components={components}>
|
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
|
||||||
{children}
|
{children}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,15 +26,61 @@ import {
|
||||||
thematicBreakPlugin,
|
thematicBreakPlugin,
|
||||||
type RealmPlugin,
|
type RealmPlugin,
|
||||||
} from "@mdxeditor/editor";
|
} 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";
|
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 ---- */
|
/* ---- Mention types ---- */
|
||||||
|
|
||||||
export interface MentionOption {
|
export interface MentionOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
kind?: "agent" | "project";
|
kind?: "agent" | "project";
|
||||||
|
agentId?: string;
|
||||||
|
agentIcon?: string | null;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectColor?: string | null;
|
projectColor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +110,12 @@ function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
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 ---- */
|
/* ---- Mention detection helpers ---- */
|
||||||
|
|
||||||
interface MentionState {
|
interface MentionState {
|
||||||
|
|
@ -154,7 +205,8 @@ function mentionMarkdown(option: MentionOption): string {
|
||||||
if (option.kind === "project" && option.projectId) {
|
if (option.kind === "project" && option.projectId) {
|
||||||
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
|
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 `@<query>` in the markdown string with the selected mention token. */
|
/** Replace `@<query>` 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);
|
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 ---- */
|
/* ---- Component ---- */
|
||||||
|
|
||||||
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
||||||
|
|
@ -221,11 +248,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
const mentionStateRef = useRef<MentionState | null>(null);
|
const mentionStateRef = useRef<MentionState | null>(null);
|
||||||
const [mentionIndex, setMentionIndex] = useState(0);
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
|
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
|
||||||
const projectColorById = useMemo(() => {
|
const mentionOptionByKey = useMemo(() => {
|
||||||
const map = new Map<string, string | null>();
|
const map = new Map<string, MentionOption>();
|
||||||
for (const mention of mentions ?? []) {
|
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) {
|
if (mention.kind === "project" && mention.projectId) {
|
||||||
map.set(mention.projectId, mention.projectColor ?? null);
|
map.set(`project:${mention.projectId}`, mention);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|
@ -286,8 +317,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
listsPlugin(),
|
listsPlugin(),
|
||||||
quotePlugin(),
|
quotePlugin(),
|
||||||
tablePlugin(),
|
tablePlugin(),
|
||||||
linkPlugin(),
|
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
|
||||||
linkDialogPlugin(),
|
linkDialogPlugin(),
|
||||||
|
mentionDeletionPlugin(),
|
||||||
thematicBreakPlugin(),
|
thematicBreakPlugin(),
|
||||||
codeBlockPlugin({
|
codeBlockPlugin({
|
||||||
defaultCodeBlockLanguage: "txt",
|
defaultCodeBlockLanguage: "txt",
|
||||||
|
|
@ -315,31 +347,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
const links = editable.querySelectorAll("a");
|
const links = editable.querySelectorAll("a");
|
||||||
for (const node of links) {
|
for (const node of links) {
|
||||||
const link = node as HTMLAnchorElement;
|
const link = node as HTMLAnchorElement;
|
||||||
const parsed = parseProjectMentionHref(link.getAttribute("href") ?? "");
|
const parsed = parseMentionChipHref(link.getAttribute("href") ?? "");
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
if (link.dataset.projectMention === "true") {
|
clearMentionChipDecoration(link);
|
||||||
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");
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = parsed.color ?? projectColorById.get(parsed.projectId) ?? null;
|
if (parsed.kind === "project") {
|
||||||
link.dataset.projectMention = "true";
|
const option = mentionOptionByKey.get(`project:${parsed.projectId}`);
|
||||||
link.classList.add("paperclip-project-mention-chip");
|
applyMentionChipDecoration(link, {
|
||||||
link.setAttribute("contenteditable", "false");
|
...parsed,
|
||||||
const style = mentionChipStyle(color);
|
color: parsed.color ?? option?.projectColor ?? null,
|
||||||
if (style) {
|
});
|
||||||
link.style.borderColor = style.borderColor ?? "";
|
continue;
|
||||||
link.style.backgroundColor = style.backgroundColor ?? "";
|
|
||||||
link.style.color = style.color ?? "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Mention detection: listen for selection changes and input events
|
||||||
const checkMention = useCallback(() => {
|
const checkMention = useCallback(() => {
|
||||||
|
|
@ -395,94 +424,67 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
// update state between the last render and this callback firing).
|
// update state between the last render and this callback firing).
|
||||||
const state = mentionStateRef.current;
|
const state = mentionStateRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
const current = latestValueRef.current;
|
||||||
if (option.kind === "project" && option.projectId) {
|
const next = applyMention(current, state.query, option);
|
||||||
const current = latestValueRef.current;
|
if (next !== current) {
|
||||||
const next = applyMention(current, state.query, option);
|
latestValueRef.current = next;
|
||||||
if (next !== current) {
|
ref.current?.setMarkdown(next);
|
||||||
latestValueRef.current = next;
|
onChange(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" });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
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;
|
mentionStateRef.current = null;
|
||||||
|
|
@ -588,6 +590,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
|
additionalLexicalNodes={[mentionAwareLinkNodeReplacement]}
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -616,7 +619,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">@</span>
|
<AgentIcon
|
||||||
|
icon={option.agentIcon}
|
||||||
|
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{option.name}</span>
|
<span>{option.name}</span>
|
||||||
{option.kind === "project" && option.projectId && (
|
{option.kind === "project" && option.projectId && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
|
@ -56,15 +57,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
||||||
const DRAFT_KEY = "paperclip:issue-draft";
|
const DRAFT_KEY = "paperclip:issue-draft";
|
||||||
const DEBOUNCE_MS = 800;
|
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 {
|
interface IssueDraft {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -376,6 +368,8 @@ export function NewIssueDialog() {
|
||||||
id: `agent:${agent.id}`,
|
id: `agent:${agent.id}`,
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
|
agentId: agent.id,
|
||||||
|
agentIcon: agent.icon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const project of orderedProjects) {
|
for (const project of orderedProjects) {
|
||||||
|
|
@ -913,7 +907,7 @@ export function NewIssueDialog() {
|
||||||
dialogCompany?.brandColor
|
dialogCompany?.brandColor
|
||||||
? {
|
? {
|
||||||
backgroundColor: dialogCompany.brandColor,
|
backgroundColor: dialogCompany.brandColor,
|
||||||
color: getContrastTextColor(dialogCompany.brandColor),
|
color: pickTextColorForSolidBg(dialogCompany.brandColor),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -943,7 +937,7 @@ export function NewIssueDialog() {
|
||||||
c.brandColor
|
c.brandColor
|
||||||
? {
|
? {
|
||||||
backgroundColor: c.brandColor,
|
backgroundColor: c.brandColor,
|
||||||
color: getContrastTextColor(c.brandColor),
|
color: pickTextColorForSolidBg(c.brandColor),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -1211,6 +1205,7 @@ export function NewIssueDialog() {
|
||||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||||
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
||||||
<button
|
<button
|
||||||
|
data-slot="toggle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
assigneeChrome ? "bg-green-600" : "bg-muted"
|
assigneeChrome ? "bg-green-600" : "bg-muted"
|
||||||
|
|
|
||||||
|
|
@ -887,6 +887,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
</div>
|
</div>
|
||||||
{onUpdate || onFieldUpdate ? (
|
{onUpdate || onFieldUpdate ? (
|
||||||
<button
|
<button
|
||||||
|
data-slot="toggle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
|
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
|
||||||
|
|
@ -925,6 +926,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
data-slot="toggle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
|
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ export function ToggleField({
|
||||||
{hint && <HintIcon text={hint} />}
|
{hint && <HintIcon text={hint} />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
data-slot="toggle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
checked ? "bg-green-600" : "bg-muted"
|
checked ? "bg-green-600" : "bg-muted"
|
||||||
|
|
@ -165,6 +166,7 @@ export function ToggleWithNumber({
|
||||||
{hint && <HintIcon text={hint} />}
|
{hint && <HintIcon text={hint} />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
data-slot="toggle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
|
||||||
checked ? "bg-green-600" : "bg-muted"
|
checked ? "bg-green-600" : "bg-muted"
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,85 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
||||||
|
it("suppresses activity toasts for the issue page currently in view", () => {
|
||||||
|
const queryClient = {
|
||||||
|
getQueryData: (key: unknown) => {
|
||||||
|
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-759",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||||
|
queryClient as never,
|
||||||
|
"/PAP/issues/PAP-759",
|
||||||
|
{
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: "issue-1",
|
||||||
|
details: { identifier: "PAP-759" },
|
||||||
|
},
|
||||||
|
{ isForegrounded: true },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||||
|
queryClient as never,
|
||||||
|
"/PAP/issues/PAP-759",
|
||||||
|
{
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: "issue-2",
|
||||||
|
details: { identifier: "PAP-760" },
|
||||||
|
},
|
||||||
|
{ isForegrounded: true },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses run and agent status toasts for the assignee of the visible issue", () => {
|
||||||
|
const queryClient = {
|
||||||
|
getQueryData: (key: unknown) => {
|
||||||
|
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-759",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(
|
||||||
|
queryClient as never,
|
||||||
|
"/PAP/issues/PAP-759",
|
||||||
|
{
|
||||||
|
runId: "run-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
},
|
||||||
|
{ isForegrounded: true },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__liveUpdatesTestUtils.shouldSuppressAgentStatusToastForVisibleIssue(
|
||||||
|
queryClient as never,
|
||||||
|
"/PAP/issues/PAP-759",
|
||||||
|
{
|
||||||
|
agentId: "agent-1",
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
{ isForegrounded: true },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
||||||
|
import type { RunForIssue } from "../api/activity";
|
||||||
|
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { useCompany } from "./CompanyContext";
|
import { useCompany } from "./CompanyContext";
|
||||||
import type { ToastInput } from "./ToastContext";
|
import type { ToastInput } from "./ToastContext";
|
||||||
import { useToast } from "./ToastContext";
|
import { useToast } from "./ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||||
|
import { useLocation } from "../lib/router";
|
||||||
|
|
||||||
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||||
const TOAST_COOLDOWN_MAX = 3;
|
const TOAST_COOLDOWN_MAX = 3;
|
||||||
|
|
@ -63,6 +67,16 @@ interface IssueToastContext {
|
||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VisibleRouteOptions {
|
||||||
|
isForegrounded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisibleIssueRouteContext {
|
||||||
|
issueRefs: Set<string>;
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
runIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveIssueQueryRefs(
|
function resolveIssueQueryRefs(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|
@ -125,6 +139,110 @@ function resolveIssueToastContext(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPageForegrounded(): boolean {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
if (document.visibilityState !== "visible") return false;
|
||||||
|
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVisibleIssueRouteContext(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
pathname: string,
|
||||||
|
options?: VisibleRouteOptions,
|
||||||
|
): VisibleIssueRouteContext | null {
|
||||||
|
const isForegrounded = options?.isForegrounded ?? isPageForegrounded();
|
||||||
|
if (!isForegrounded) return null;
|
||||||
|
|
||||||
|
const relativePath = toCompanyRelativePath(pathname);
|
||||||
|
const segments = relativePath.split("/").filter(Boolean);
|
||||||
|
if (segments[0] !== "issues" || !segments[1]) return null;
|
||||||
|
|
||||||
|
const issueRef = decodeURIComponent(segments[1]);
|
||||||
|
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
|
||||||
|
const issueRefs = new Set<string>([issueRef]);
|
||||||
|
if (issue?.id) issueRefs.add(issue.id);
|
||||||
|
if (issue?.identifier) issueRefs.add(issue.identifier);
|
||||||
|
|
||||||
|
const runIds = new Set<string>();
|
||||||
|
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueRef));
|
||||||
|
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueRef)) ?? [];
|
||||||
|
const linkedRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueRef)) ?? [];
|
||||||
|
|
||||||
|
if (activeRun?.id) runIds.add(activeRun.id);
|
||||||
|
for (const run of liveRuns) {
|
||||||
|
if (run.id) runIds.add(run.id);
|
||||||
|
}
|
||||||
|
for (const run of linkedRuns) {
|
||||||
|
if (run.runId) runIds.add(run.runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issueRefs,
|
||||||
|
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
||||||
|
runIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIssueRefsForPayload(entityId: string, details: Record<string, unknown> | null): Set<string> {
|
||||||
|
const refs = new Set<string>([entityId]);
|
||||||
|
const identifier = readString(details?.identifier) ?? readString(details?.issueIdentifier);
|
||||||
|
if (identifier) refs.add(identifier);
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a: Set<string>, b: Set<string>): boolean {
|
||||||
|
for (const value of a) {
|
||||||
|
if (b.has(value)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuppressActivityToastForVisibleIssue(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
pathname: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
options?: VisibleRouteOptions,
|
||||||
|
): boolean {
|
||||||
|
const entityType = readString(payload.entityType);
|
||||||
|
const entityId = readString(payload.entityId);
|
||||||
|
if (entityType !== "issue" || !entityId) return false;
|
||||||
|
|
||||||
|
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||||
|
if (!context) return false;
|
||||||
|
|
||||||
|
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuppressRunStatusToastForVisibleIssue(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
pathname: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
options?: VisibleRouteOptions,
|
||||||
|
): boolean {
|
||||||
|
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||||
|
if (!context) return false;
|
||||||
|
|
||||||
|
const runId = readString(payload.runId);
|
||||||
|
if (runId && context.runIds.has(runId)) return true;
|
||||||
|
|
||||||
|
const agentId = readString(payload.agentId);
|
||||||
|
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuppressAgentStatusToastForVisibleIssue(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
pathname: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
options?: VisibleRouteOptions,
|
||||||
|
): boolean {
|
||||||
|
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||||
|
if (!context?.assigneeAgentId) return false;
|
||||||
|
|
||||||
|
const agentId = readString(payload.agentId);
|
||||||
|
return !!agentId && agentId === context.assigneeAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||||
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
||||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
||||||
|
|
@ -470,6 +588,7 @@ function gatedPushToast(
|
||||||
function handleLiveEvent(
|
function handleLiveEvent(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
expectedCompanyId: string,
|
expectedCompanyId: string,
|
||||||
|
pathname: string,
|
||||||
event: LiveEvent,
|
event: LiveEvent,
|
||||||
pushToast: (toast: ToastInput) => string | null,
|
pushToast: (toast: ToastInput) => string | null,
|
||||||
gate: ToastGate,
|
gate: ToastGate,
|
||||||
|
|
@ -487,7 +606,12 @@ function handleLiveEvent(
|
||||||
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||||
if (event.type === "heartbeat.run.status") {
|
if (event.type === "heartbeat.run.status") {
|
||||||
const toast = buildRunStatusToast(payload, nameOf);
|
const toast = buildRunStatusToast(payload, nameOf);
|
||||||
if (toast) gatedPushToast(gate, pushToast, "run-status", toast);
|
if (
|
||||||
|
toast &&
|
||||||
|
!shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||||
|
) {
|
||||||
|
gatedPushToast(gate, pushToast, "run-status", toast);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -503,7 +627,12 @@ function handleLiveEvent(
|
||||||
const agentId = readString(payload.agentId);
|
const agentId = readString(payload.agentId);
|
||||||
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||||
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
|
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
|
||||||
if (toast) gatedPushToast(gate, pushToast, "agent-status", toast);
|
if (
|
||||||
|
toast &&
|
||||||
|
!shouldSuppressAgentStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||||
|
) {
|
||||||
|
gatedPushToast(gate, pushToast, "agent-status", toast);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,19 +642,29 @@ function handleLiveEvent(
|
||||||
const toast =
|
const toast =
|
||||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||||
buildJoinRequestToast(payload);
|
buildJoinRequestToast(payload);
|
||||||
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
if (
|
||||||
|
toast &&
|
||||||
|
!shouldSuppressActivityToastForVisibleIssue(queryClient, pathname, payload)
|
||||||
|
) {
|
||||||
|
gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __liveUpdatesTestUtils = {
|
export const __liveUpdatesTestUtils = {
|
||||||
invalidateActivityQueries,
|
invalidateActivityQueries,
|
||||||
|
shouldSuppressActivityToastForVisibleIssue,
|
||||||
|
shouldSuppressRunStatusToastForVisibleIssue,
|
||||||
|
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
|
const location = useLocation();
|
||||||
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||||
|
const pathnameRef = useRef(location.pathname);
|
||||||
const { data: session } = useQuery({
|
const { data: session } = useQuery({
|
||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
queryFn: () => authApi.getSession(),
|
||||||
|
|
@ -533,6 +672,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pathnameRef.current = location.pathname;
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCompanyId) return;
|
if (!selectedCompanyId) return;
|
||||||
|
|
||||||
|
|
@ -577,7 +720,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as LiveEvent;
|
const parsed = JSON.parse(raw) as LiveEvent;
|
||||||
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, {
|
handleLiveEvent(queryClient, selectedCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
agentId: null,
|
agentId: null,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,10 @@
|
||||||
[data-slot="select-trigger"] {
|
[data-slot="select-trigger"] {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="toggle"] {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode scrollbars */
|
/* Dark mode scrollbars */
|
||||||
|
|
@ -339,6 +343,7 @@
|
||||||
margin-top: 1.1em;
|
margin-top: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paperclip-mdxeditor-content a.paperclip-mention-chip,
|
||||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -355,6 +360,35 @@
|
||||||
user-select: none;
|
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 ul,
|
||||||
.paperclip-mdxeditor-content ol {
|
.paperclip-mdxeditor-content ol {
|
||||||
margin: 1.1em 0;
|
margin: 1.1em 0;
|
||||||
|
|
@ -415,7 +449,7 @@
|
||||||
|
|
||||||
.paperclip-mdxeditor-content code {
|
.paperclip-mdxeditor-content code {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
font-size: 0.78em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-content pre {
|
.paperclip-mdxeditor-content pre {
|
||||||
|
|
@ -433,7 +467,7 @@
|
||||||
.paperclip-mdxeditor .cm-editor {
|
.paperclip-mdxeditor .cm-editor {
|
||||||
background-color: #1e1e2e !important;
|
background-color: #1e1e2e !important;
|
||||||
color: #cdd6f4 !important;
|
color: #cdd6f4 !important;
|
||||||
font-size: 0.78em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor .cm-gutters {
|
.paperclip-mdxeditor .cm-gutters {
|
||||||
|
|
@ -513,14 +547,14 @@
|
||||||
color: #cdd6f4 !important;
|
color: #cdd6f4 !important;
|
||||||
padding: 0.5rem 0.65rem !important;
|
padding: 0.5rem 0.65rem !important;
|
||||||
margin: 0.4rem 0 !important;
|
margin: 0.4rem 0 !important;
|
||||||
font-size: 0.78em !important;
|
font-size: 1em !important;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-markdown code {
|
.paperclip-markdown code {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
font-size: 0.78em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-markdown pre code {
|
.paperclip-markdown pre code {
|
||||||
|
|
@ -543,6 +577,10 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .prose :not(pre) > code {
|
||||||
|
background-color: #ffffff0f;
|
||||||
|
}
|
||||||
|
|
||||||
.paperclip-markdown {
|
.paperclip-markdown {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
|
|
@ -696,6 +734,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Project mention chips rendered inside MarkdownBody */
|
/* Project mention chips rendered inside MarkdownBody */
|
||||||
|
a.paperclip-mention-chip,
|
||||||
a.paperclip-project-mention-chip {
|
a.paperclip-project-mention-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
98
ui/src/lib/agent-icons.ts
Normal file
98
ui/src/lib/agent-icons.ts
Normal file
|
|
@ -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<AgentIconName, LucideIcon> = {
|
||||||
|
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];
|
||||||
|
}
|
||||||
107
ui/src/lib/color-contrast.ts
Normal file
107
ui/src/lib/color-contrast.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
167
ui/src/lib/mention-chips.ts
Normal file
167
ui/src/lib/mention-chips.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
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 =
|
||||||
|
| {
|
||||||
|
kind: "agent";
|
||||||
|
agentId: string;
|
||||||
|
icon: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "project";
|
||||||
|
projectId: string;
|
||||||
|
color: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconMaskCache = new Map<string, string>();
|
||||||
|
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
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<string, string>)[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<CSSProperties, "borderColor" | "backgroundColor" | "color"> {
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
|
if (!rgb) return {};
|
||||||
|
return {
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
|
||||||
|
color: pickTextColorForPillBg(color),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 iconNode = resolveLucideIconNode(Icon);
|
||||||
|
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}` : ""}></${tag}>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ` +
|
||||||
|
`fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" ` +
|
||||||
|
`stroke-linejoin="round">${body}</svg>`;
|
||||||
|
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
iconMaskCache.set(cacheKey, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLucideIconNode(
|
||||||
|
icon: unknown,
|
||||||
|
): Array<[string, Record<string, string>]> | null {
|
||||||
|
const staticIconNode = (
|
||||||
|
icon as {
|
||||||
|
iconNode?: Array<[string, Record<string, string>]>;
|
||||||
|
}
|
||||||
|
).iconNode;
|
||||||
|
if (Array.isArray(staticIconNode) && staticIconNode.length > 0) {
|
||||||
|
return staticIconNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = (
|
||||||
|
icon as {
|
||||||
|
render?: (props: Record<string, unknown>, ref: unknown) => {
|
||||||
|
props?: { iconNode?: Array<[string, Record<string, string>]> };
|
||||||
|
} | 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("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
86
ui/src/lib/mention-deletion.test.ts
Normal file
86
ui/src/lib/mention-deletion.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { $createLinkNode, LinkNode } from "@lexical/link";
|
||||||
|
import { buildAgentMentionHref } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
createEditor,
|
||||||
|
$createParagraphNode,
|
||||||
|
$createTextNode,
|
||||||
|
$getRoot,
|
||||||
|
$getSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
} from "lexical";
|
||||||
|
import { deleteSelectedMentionChip } from "./mention-deletion";
|
||||||
|
|
||||||
|
function createTestEditor() {
|
||||||
|
return createEditor({
|
||||||
|
namespace: "mention-deletion-test",
|
||||||
|
nodes: [LinkNode],
|
||||||
|
onError(error: Error) {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mention deletion", () => {
|
||||||
|
it("removes the full mention when backspacing from inside the chip", () => {
|
||||||
|
const editor = createTestEditor();
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
const before = $createTextNode("Hello ");
|
||||||
|
const mention = $createLinkNode(buildAgentMentionHref("agent-123", "code"));
|
||||||
|
const mentionText = $createTextNode("@QA");
|
||||||
|
const after = $createTextNode(" world");
|
||||||
|
|
||||||
|
mention.append(mentionText);
|
||||||
|
paragraph.append(before, mention, after);
|
||||||
|
root.append(paragraph);
|
||||||
|
|
||||||
|
mentionText.selectEnd();
|
||||||
|
|
||||||
|
expect(deleteSelectedMentionChip("backward")).toBe(true);
|
||||||
|
expect(root.getTextContent()).toBe("Hello world");
|
||||||
|
|
||||||
|
const selection = $getSelection();
|
||||||
|
expect($isRangeSelection(selection)).toBe(true);
|
||||||
|
if (!$isRangeSelection(selection)) {
|
||||||
|
throw new Error("Expected range selection after backward mention deletion");
|
||||||
|
}
|
||||||
|
expect(selection.isCollapsed()).toBe(true);
|
||||||
|
expect(selection.anchor.getNode().is(before)).toBe(true);
|
||||||
|
expect(selection.anchor.offset).toBe(before.getTextContentSize());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the full mention when deleting forward from adjacent text", () => {
|
||||||
|
const editor = createTestEditor();
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
const before = $createTextNode("Hello ");
|
||||||
|
const mention = $createLinkNode(buildAgentMentionHref("agent-123", "code"));
|
||||||
|
const mentionText = $createTextNode("@QA");
|
||||||
|
const after = $createTextNode(" world");
|
||||||
|
|
||||||
|
mention.append(mentionText);
|
||||||
|
paragraph.append(before, mention, after);
|
||||||
|
root.append(paragraph);
|
||||||
|
|
||||||
|
before.selectEnd();
|
||||||
|
|
||||||
|
expect(deleteSelectedMentionChip("forward")).toBe(true);
|
||||||
|
expect(root.getTextContent()).toBe("Hello world");
|
||||||
|
|
||||||
|
const selection = $getSelection();
|
||||||
|
expect($isRangeSelection(selection)).toBe(true);
|
||||||
|
if (!$isRangeSelection(selection)) {
|
||||||
|
throw new Error("Expected range selection after forward mention deletion");
|
||||||
|
}
|
||||||
|
expect(selection.isCollapsed()).toBe(true);
|
||||||
|
expect(selection.anchor.getNode().is(after)).toBe(true);
|
||||||
|
expect(selection.anchor.offset).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
ui/src/lib/mention-deletion.ts
Normal file
143
ui/src/lib/mention-deletion.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { createRootEditorSubscription$, realmPlugin } from "@mdxeditor/editor";
|
||||||
|
import { $isLinkNode, type LinkNode } from "@lexical/link";
|
||||||
|
import {
|
||||||
|
$getSelection,
|
||||||
|
$isElementNode,
|
||||||
|
$isNodeSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
$isTextNode,
|
||||||
|
COMMAND_PRIORITY_HIGH,
|
||||||
|
KEY_BACKSPACE_COMMAND,
|
||||||
|
KEY_DELETE_COMMAND,
|
||||||
|
type LexicalNode,
|
||||||
|
type PointType,
|
||||||
|
} from "lexical";
|
||||||
|
import { parseMentionChipHref } from "./mention-chips";
|
||||||
|
|
||||||
|
export type MentionDeletionDirection = "backward" | "forward";
|
||||||
|
|
||||||
|
function isMentionLinkNode(node: LexicalNode | null | undefined): node is LinkNode {
|
||||||
|
return Boolean(node && $isLinkNode(node) && parseMentionChipHref(node.getURL()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMentionLinkNode(node: LexicalNode | null | undefined): LinkNode | null {
|
||||||
|
if (!node) return null;
|
||||||
|
if (isMentionLinkNode(node)) return node;
|
||||||
|
|
||||||
|
let parent = node.getParent();
|
||||||
|
while (parent) {
|
||||||
|
if (isMentionLinkNode(parent)) return parent;
|
||||||
|
parent = parent.getParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMentionLinkNodeAtPoint(point: PointType, direction: MentionDeletionDirection): LinkNode | null {
|
||||||
|
const node = point.getNode();
|
||||||
|
const directMention = findMentionLinkNode(node);
|
||||||
|
if (directMention) return directMention;
|
||||||
|
|
||||||
|
if (point.type === "element" && $isElementNode(node)) {
|
||||||
|
const childIndex = direction === "backward" ? point.offset - 1 : point.offset;
|
||||||
|
if (childIndex < 0) return null;
|
||||||
|
return findMentionLinkNode(node.getChildAtIndex(childIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point.type === "text" && $isTextNode(node)) {
|
||||||
|
if (direction === "backward" && point.offset === 0) {
|
||||||
|
return findMentionLinkNode(node.getPreviousSibling());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "forward" && point.offset === node.getTextContentSize()) {
|
||||||
|
return findMentionLinkNode(node.getNextSibling());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMentionLinkForDeletion(direction: MentionDeletionDirection): LinkNode | null {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if (!selection) return null;
|
||||||
|
|
||||||
|
if ($isNodeSelection(selection)) {
|
||||||
|
const [selectedNode] = selection.getNodes();
|
||||||
|
return selectedNode ? findMentionLinkNode(selectedNode) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isRangeSelection(selection)) return null;
|
||||||
|
|
||||||
|
const anchorMention = findMentionLinkNode(selection.anchor.getNode());
|
||||||
|
const focusMention = findMentionLinkNode(selection.focus.getNode());
|
||||||
|
if (anchorMention && focusMention && anchorMention.is(focusMention)) {
|
||||||
|
return anchorMention;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selection.isCollapsed()) return null;
|
||||||
|
|
||||||
|
return findMentionLinkNodeAtPoint(selection.anchor, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSelectedMentionChip(direction: MentionDeletionDirection): boolean {
|
||||||
|
const mentionNode = findMentionLinkForDeletion(direction);
|
||||||
|
if (!mentionNode) return false;
|
||||||
|
|
||||||
|
const previousSibling = mentionNode.getPreviousSibling();
|
||||||
|
const nextSibling = mentionNode.getNextSibling();
|
||||||
|
const parent = mentionNode.getParentOrThrow();
|
||||||
|
|
||||||
|
mentionNode.remove();
|
||||||
|
|
||||||
|
if (direction === "backward") {
|
||||||
|
if (previousSibling) {
|
||||||
|
previousSibling.selectEnd();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (nextSibling) {
|
||||||
|
nextSibling.selectStart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
parent.selectStart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSibling) {
|
||||||
|
nextSibling.selectStart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (previousSibling) {
|
||||||
|
previousSibling.selectEnd();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
parent.selectEnd();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMentionDelete(direction: MentionDeletionDirection, event: KeyboardEvent | null): boolean {
|
||||||
|
const didDelete = deleteSelectedMentionChip(direction);
|
||||||
|
if (!didDelete) return false;
|
||||||
|
|
||||||
|
event?.preventDefault();
|
||||||
|
event?.stopPropagation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mentionDeletionPlugin = realmPlugin({
|
||||||
|
init(realm) {
|
||||||
|
realm.pub(createRootEditorSubscription$, [
|
||||||
|
(editor) =>
|
||||||
|
editor.registerCommand(
|
||||||
|
KEY_BACKSPACE_COMMAND,
|
||||||
|
(event) => handleMentionDelete("backward", event as KeyboardEvent | null),
|
||||||
|
COMMAND_PRIORITY_HIGH,
|
||||||
|
),
|
||||||
|
(editor) =>
|
||||||
|
editor.registerCommand(
|
||||||
|
KEY_DELETE_COMMAND,
|
||||||
|
(event) => handleMentionDelete("forward", event as KeyboardEvent | null),
|
||||||
|
COMMAND_PRIORITY_HIGH,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -69,6 +69,7 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
@ -1512,6 +1513,7 @@ function ConfigurationTab({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
|
data-slot="toggle"
|
||||||
aria-checked={canCreateAgents}
|
aria-checked={canCreateAgents}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
|
@ -1543,6 +1545,7 @@ function ConfigurationTab({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
|
data-slot="toggle"
|
||||||
aria-checked={canAssignTasks}
|
aria-checked={canAssignTasks}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
|
@ -1589,7 +1592,9 @@ function PromptsTab({
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
const [selectedFile, setSelectedFile] = useState<string>("AGENTS.md");
|
const [selectedFile, setSelectedFile] = useState<string>("AGENTS.md");
|
||||||
|
const [showFilePanel, setShowFilePanel] = useState(false);
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
const [bundleDraft, setBundleDraft] = useState<{
|
const [bundleDraft, setBundleDraft] = useState<{
|
||||||
mode: "managed" | "external";
|
mode: "managed" | "external";
|
||||||
|
|
@ -2042,21 +2047,38 @@ function PromptsTab({
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<div ref={containerRef} className="flex gap-0">
|
<div ref={containerRef} className={cn("flex gap-0", isMobile && "flex-col gap-3")}>
|
||||||
<div className="border border-border rounded-lg p-3 space-y-3 shrink-0" style={{ width: filePanelWidth }}>
|
<div className={cn(
|
||||||
|
"border border-border rounded-lg p-3 space-y-3 shrink-0",
|
||||||
|
isMobile && showFilePanel && "block",
|
||||||
|
isMobile && !showFilePanel && "hidden",
|
||||||
|
)} style={isMobile ? undefined : { width: filePanelWidth }}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-sm font-medium">Files</h4>
|
<h4 className="text-sm font-medium">Files</h4>
|
||||||
{!showNewFileInput && (
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
{!showNewFileInput && (
|
||||||
type="button"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
variant="outline"
|
size="icon"
|
||||||
className="h-7 w-7"
|
variant="outline"
|
||||||
onClick={() => setShowNewFileInput(true)}
|
className="h-7 w-7"
|
||||||
>
|
onClick={() => setShowNewFileInput(true)}
|
||||||
+
|
>
|
||||||
</Button>
|
+
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setShowFilePanel(false)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showNewFileInput && (
|
{showNewFileInput && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -2121,6 +2143,7 @@ function PromptsTab({
|
||||||
onSelectFile={(filePath) => {
|
onSelectFile={(filePath) => {
|
||||||
setSelectedFile(filePath);
|
setSelectedFile(filePath);
|
||||||
if (!fileOptions.includes(filePath)) setDraft("");
|
if (!fileOptions.includes(filePath)) setDraft("");
|
||||||
|
if (isMobile) setShowFilePanel(false);
|
||||||
}}
|
}}
|
||||||
onToggleCheck={() => {}}
|
onToggleCheck={() => {}}
|
||||||
showCheckboxes={false}
|
showCheckboxes={false}
|
||||||
|
|
@ -2151,22 +2174,37 @@ function PromptsTab({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draggable separator */}
|
{/* Draggable separator */}
|
||||||
<div
|
{!isMobile && (
|
||||||
className="w-1 shrink-0 cursor-col-resize hover:bg-border active:bg-primary/50 rounded transition-colors mx-1"
|
<div
|
||||||
onMouseDown={handleSeparatorDrag}
|
className="w-1 shrink-0 cursor-col-resize hover:bg-border active:bg-primary/50 rounded transition-colors mx-1"
|
||||||
/>
|
onMouseDown={handleSeparatorDrag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border border-border rounded-lg p-4 space-y-3 min-w-0 flex-1">
|
<div className={cn("border border-border rounded-lg p-4 space-y-3 min-w-0 flex-1", isMobile && showFilePanel && "hidden")}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4>
|
{isMobile && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<Button
|
||||||
{selectedFileExists
|
type="button"
|
||||||
? selectedFileSummary?.deprecated
|
size="icon"
|
||||||
? "Deprecated virtual file"
|
variant="outline"
|
||||||
: `${selectedFileDetail?.language ?? "text"} file`
|
className="h-7 w-7 shrink-0"
|
||||||
: "New file in this bundle"}
|
onClick={() => setShowFilePanel(true)}
|
||||||
</p>
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="text-sm font-medium font-mono truncate">{selectedOrEntryFile}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{selectedFileExists
|
||||||
|
? selectedFileSummary?.deprecated
|
||||||
|
? "Deprecated virtual file"
|
||||||
|
: `${selectedFileDetail?.language ?? "text"} file`
|
||||||
|
: "New file in this bundle"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
|
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -45,17 +45,21 @@ function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean):
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
|
function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
|
||||||
return agents.filter((a) => matchesFilter(a.status, tab, showTerminated));
|
return agents
|
||||||
|
.filter((a) => matchesFilter(a.status, tab, showTerminated))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
|
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
|
||||||
return nodes.reduce<OrgNode[]>((acc, node) => {
|
return nodes
|
||||||
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
|
.reduce<OrgNode[]>((acc, node) => {
|
||||||
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
|
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
|
||||||
acc.push({ ...node, reports: filteredReports });
|
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
|
||||||
}
|
acc.push({ ...node, reports: filteredReports });
|
||||||
return acc;
|
}
|
||||||
}, []);
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Agents() {
|
export function Agents() {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export function AuthPage() {
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="mt-6 space-y-4"
|
className="mt-6 space-y-4"
|
||||||
|
method="post"
|
||||||
|
action={mode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (mutation.isPending) return;
|
if (mutation.isPending) return;
|
||||||
|
|
@ -101,8 +103,10 @@ export function AuthPage() {
|
||||||
>
|
>
|
||||||
{mode === "sign_up" && (
|
{mode === "sign_up" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">Name</label>
|
<label htmlFor="name" className="text-xs text-muted-foreground mb-1 block">Name</label>
|
||||||
<input
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
|
@ -112,8 +116,10 @@ export function AuthPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">Email</label>
|
<label htmlFor="email" className="text-xs text-muted-foreground mb-1 block">Email</label>
|
||||||
<input
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
|
|
@ -123,8 +129,10 @@ export function AuthPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">Password</label>
|
<label htmlFor="password" className="text-xs text-muted-foreground mb-1 block">Password</label>
|
||||||
<input
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
|
||||||
import { ActivityRow } from "../components/ActivityRow";
|
import { ActivityRow } from "../components/ActivityRow";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
|
|
@ -356,7 +356,6 @@ export function Dashboard() {
|
||||||
{issue.title}
|
{issue.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||||
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
|
|
||||||
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
|
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
||||||
|
|
@ -767,9 +767,6 @@ export function Inbox() {
|
||||||
issueLinkState={issueLinkState}
|
issueLinkState={issueLinkState}
|
||||||
desktopMetaLeading={(
|
desktopMetaLeading={(
|
||||||
<>
|
<>
|
||||||
<span className="hidden sm:inline-flex">
|
|
||||||
<PriorityIcon priority={issue.priority} />
|
|
||||||
</span>
|
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
<StatusIcon status={issue.status} />
|
<StatusIcon status={issue.status} />
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export function InstanceExperimentalSettings() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="toggle"
|
||||||
aria-label="Toggle isolated workspaces experimental setting"
|
aria-label="Toggle isolated workspaces experimental setting"
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -113,6 +114,7 @@ export function InstanceExperimentalSettings() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="toggle"
|
||||||
aria-label="Toggle guarded dev-server auto-restart"
|
aria-label="Toggle guarded dev-server auto-restart"
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ export function InstanceGeneralSettings() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-slot="toggle"
|
||||||
aria-label="Toggle username log censoring"
|
aria-label="Toggle username log censoring"
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
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 { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
|
@ -341,6 +342,8 @@ export function IssueDetail() {
|
||||||
id: `agent:${agent.id}`,
|
id: `agent:${agent.id}`,
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
|
agentId: agent.id,
|
||||||
|
agentIcon: agent.icon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const project of orderedProjects) {
|
for (const project of orderedProjects) {
|
||||||
|
|
@ -670,7 +673,12 @@ export function IssueDetail() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline">Upload attachment</span>
|
||||||
|
<span className="sm:hidden">Upload</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -760,7 +768,7 @@ export function IssueDetail() {
|
||||||
className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
borderColor: label.color,
|
borderColor: label.color,
|
||||||
color: label.color,
|
color: pickTextColorForPillBg(label.color, 0.12),
|
||||||
backgroundColor: `${label.color}1f`,
|
backgroundColor: `${label.color}1f`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
|
@ -56,10 +56,7 @@ export function MyIssues() {
|
||||||
title={issue.title}
|
title={issue.title}
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
leading={
|
leading={
|
||||||
<>
|
<StatusIcon status={issue.status} />
|
||||||
<PriorityIcon priority={issue.priority} />
|
|
||||||
<StatusIcon status={issue.status} />
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
trailing={
|
trailing={
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -647,6 +647,7 @@ export function RoutineDetail() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
|
data-slot="toggle"
|
||||||
aria-checked={automationEnabled}
|
aria-checked={automationEnabled}
|
||||||
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
||||||
disabled={automationToggleDisabled}
|
disabled={automationToggleDisabled}
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,7 @@ export function Routines() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
|
data-slot="toggle"
|
||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||||
disabled={isStatusPending || isArchived}
|
disabled={isStatusPending || isArchived}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"lexical": ["./node_modules/lexical/index.d.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
|
import path from "path";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue