From 2615450afcc9fc8588884586337a77b907fb728b Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 09:55:10 -0500 Subject: [PATCH 01/11] Make close workspace modal responsive for mobile - Reduce padding and text sizes on small screens (p-4/text-xs -> sm:p-6/sm:text-sm) - Tighter spacing between sections on mobile (space-y-3 -> sm:space-y-4) - Sticky footer so action buttons stay visible while scrolling - Grid layout stays 2-col on all sizes for git status - Add shrink-0 to loading spinner Co-Authored-By: Paperclip --- .../ExecutionWorkspaceCloseDialog.tsx | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index f0547684..1f79d52a 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} - + Archive {workspaceName} and clean up any owned workspace artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. {readinessQuery.isLoading ? ( -
- +
+ Checking whether this workspace is safe to close...
) : readinessQuery.error ? ( -
+
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
) : readiness ? ( -
-
+
+
{readiness.state === "blocked" ? "Close is blocked" @@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({ {blockingIssues.length > 0 ? (
-

Blocking issues

-
+

Blocking issues

+
{blockingIssues.map((issue) => ( -
+
{issue.identifier ?? issue.id} · {issue.title} @@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.blockingReasons.length > 0 ? (
-

Blocking reasons

-
    +

    Blocking reasons

    +
      {readiness.blockingReasons.map((reason, idx) => ( -
    • +
    • {reason}
    • ))} @@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.warnings.length > 0 ? (
      -

      Warnings

      -
        +

        Warnings

        +
          {readiness.warnings.map((warning, idx) => ( -
        • +
        • {warning}
        • ))} @@ -173,9 +173,9 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.git ? (
          -

          Git status

          -
          -
          +

          Git status

          +
          +
          Branch
          {readiness.git.branchName ?? "Unknown"}
          @@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({ {otherLinkedIssues.length > 0 ? (
          -

          Other linked issues

          -
          +

          Other linked issues

          +
          {otherLinkedIssues.map((issue) => ( -
          +
          {issue.identifier ?? issue.id} · {issue.title} @@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.runtimeServices.length > 0 ? (
          -

          Attached runtime services

          -
          +

          Attached runtime services

          +
          {readiness.runtimeServices.map((service) => ( -
          +
          {service.serviceName} {service.status} · {service.lifecycle} @@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({ ) : null}
          -

          Cleanup actions

          -
          +

          Cleanup actions

          +
          {readiness.plannedActions.map((action, index) => ( -
          +
          {action.label}
          {action.description}
          {action.command ? ( @@ -262,14 +262,14 @@ export function ExecutionWorkspaceCloseDialog({
          {currentStatus === "cleanup_failed" ? ( -
          +
          Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the workspace status if it succeeds.
          ) : null} {currentStatus === "archived" ? ( -
          +
          This workspace is already archived.
          ) : null} @@ -291,7 +291,7 @@ export function ExecutionWorkspaceCloseDialog({
          ) : null} - +
          + +
          +
          +
          +

          Sign out

          +

          + Sign out of this Paperclip instance. You will be redirected to the login page. +

          +
          + +
          +
          ); } From dbb5f0c4a9a4fca456e683a98ddb370fcb7de049 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 10:00:39 -0500 Subject: [PATCH 03/11] Unify all toggle switches into a single responsive ToggleSwitch component Replaces 12+ inline toggle button implementations across the app with a shared ToggleSwitch component that scales up on mobile for better touch targets. Default size is h-6/w-10 on mobile, h-5/w-9 on desktop; "lg" variant is h-7/w-12 on mobile, h-6/w-11 on desktop. Co-Authored-By: Paperclip --- ui/src/components/NewIssueDialog.tsx | 20 ++----- ui/src/components/ProjectProperties.tsx | 43 +++----------- ui/src/components/agent-config-primitives.tsx | 40 +++---------- ui/src/components/ui/toggle-switch.tsx | 59 +++++++++++++++++++ ui/src/pages/AgentDetail.tsx | 45 +++----------- ui/src/pages/InstanceExperimentalSettings.tsx | 48 ++++----------- ui/src/pages/InstanceGeneralSettings.tsx | 49 ++++----------- ui/src/pages/RoutineDetail.tsx | 24 +++----- ui/src/pages/Routines.tsx | 26 +++----- 9 files changed, 128 insertions(+), 226 deletions(-) create mode 100644 ui/src/components/ui/toggle-switch.tsx diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 2881a7dc..95d30d2b 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -24,6 +24,7 @@ import { DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Popover, PopoverContent, @@ -1208,21 +1209,10 @@ export function NewIssueDialog() { {assigneeAdapterType === "claude_local" && (
          Enable Chrome (--chrome)
          - + setAssigneeChrome((value) => !value)} + />
          )}
          diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 6574091c..a13eb3cc 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react"; import { ChoosePathButton } from "./PathInstructionsModal"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { DraftInput } from "./agent-config-primitives"; import { InlineEditor } from "./InlineEditor"; @@ -886,26 +887,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
          {onUpdate || onFieldUpdate ? ( - + /> ) : ( {executionWorkspacesEnabled ? "Enabled" : "Disabled"} @@ -925,14 +914,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa If disabled, new issues stay on the project's primary checkout unless someone opts in.
          - + />
          diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 76b3aef0..5c4c1491 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -4,6 +4,7 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Dialog, DialogContent, @@ -111,23 +112,11 @@ export function ToggleField({ {label} {hint && }
          - + />
          ); } @@ -162,21 +151,10 @@ export function ToggleWithNumber({ {label} {hint && }
          - +
          {showNumber && (
          diff --git a/ui/src/components/ui/toggle-switch.tsx b/ui/src/components/ui/toggle-switch.tsx new file mode 100644 index 00000000..d4995f25 --- /dev/null +++ b/ui/src/components/ui/toggle-switch.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface ToggleSwitchProps + extends Omit, "onChange"> { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + size?: "default" | "lg"; +} + +export const ToggleSwitch = React.forwardRef< + HTMLButtonElement, + ToggleSwitchProps +>( + ( + { checked, onCheckedChange, size = "default", className, disabled, ...props }, + ref, + ) => { + const isLg = size === "lg"; + + return ( + + ); + }, +); + +ToggleSwitch.displayName = "ToggleSwitch"; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 40d342c7..caa90578 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -25,6 +25,7 @@ import { queryKeys } from "../lib/queryKeys"; import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { assetsApi } from "../api/assets"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; @@ -1627,30 +1628,16 @@ function ConfigurationTab({ Lets this agent create or hire agents and implicitly assign tasks.

          - + />
@@ -1659,30 +1646,16 @@ function ConfigurationTab({ {taskAssignHint}

- + />
diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 050166ff..753ab5fd 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -4,7 +4,7 @@ import { FlaskConical } from "lucide-react"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; -import { cn } from "../lib/utils"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; export function InstanceExperimentalSettings() { const { setBreadcrumbs } = useBreadcrumbs(); @@ -82,24 +82,12 @@ export function InstanceExperimentalSettings() { and existing issue runs.

- + aria-label="Toggle isolated workspaces experimental setting" + />
@@ -112,26 +100,12 @@ export function InstanceExperimentalSettings() { automatically when backend changes or migrations make the current boot stale.

- + aria-label="Toggle guarded dev-server auto-restart" + />
diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 923c8cb8..28e00b29 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -7,6 +7,7 @@ import { instanceSettingsApi } from "@/api/instanceSettings"; import { Button } from "../components/ui/button"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { cn } from "../lib/utils"; const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; @@ -95,28 +96,12 @@ export function InstanceGeneralSettings() { default.

- + aria-label="Toggle username log censoring" + />
@@ -129,24 +114,12 @@ export function InstanceGeneralSettings() { toggling panels. This is off by default.

- + aria-label="Toggle keyboard shortcuts" + />
diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 55dc32f4..c1ca92e7 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; import { timeAgo } from "../lib/timeAgo"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; @@ -710,24 +711,13 @@ export function RoutineDetail() { }} disabled={runRoutine.isPending} /> - + aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"} + /> {automationLabel} diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index fc856d72..b58cea8c 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -11,6 +11,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; @@ -640,29 +641,18 @@ export function Routines() { e.stopPropagation()}>
- + disabled={isStatusPending || isArchived} + aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} + /> {isArchived ? "Archived" : enabled ? "On" : "Off"} From d3401c05180984a260af3d91c586ef431214af97 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 10:02:26 -0500 Subject: [PATCH 04/11] Fix horizontal scroll overflow in close workspace modal - Add overflow-x-hidden on DialogContent to prevent horizontal scroll - Truncate long monospace text (branch names, base refs) in git status grid - Add min-w-0 on grid cells to allow truncation within CSS grid - Add overflow-hidden on git status card and repo root section - Add max-w-full + overflow-x-auto on pre blocks for long commands Co-Authored-By: Paperclip --- .../ExecutionWorkspaceCloseDialog.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index 1f79d52a..c3ae8858 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -88,7 +88,7 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} @@ -107,7 +107,7 @@ export function ExecutionWorkspaceCloseDialog({ {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
) : readiness ? ( -
+
{readiness.state === "blocked" @@ -174,15 +174,15 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.git ? (

Git status

-
+
-
+
Branch
-
{readiness.git.branchName ?? "Unknown"}
+
{readiness.git.branchName ?? "Unknown"}
-
+
Base ref
-
{readiness.git.baseRef ?? "Not set"}
+
{readiness.git.baseRef ?? "Not set"}
Merged into base
@@ -252,7 +252,7 @@ export function ExecutionWorkspaceCloseDialog({
{action.label}
{action.description}
{action.command ? ( -
+                      
                         {action.command}
                       
) : null} @@ -275,7 +275,7 @@ export function ExecutionWorkspaceCloseDialog({ ) : null} {readiness.git?.repoRoot ? ( -
+
Repo root: {readiness.git.repoRoot} {readiness.git.workspacePath ? ( <> From 4993b5338c539e3858a3aa560c8cf2b4bfb73992 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 12:48:32 -0500 Subject: [PATCH 05/11] Fix horizontal scroll overflow in close workspace modal Root cause: CSS Grid items default to min-width:auto, allowing content to push the dialog wider than the viewport on mobile. - Add [&>*]:min-w-0 on DialogContent to prevent grid children from expanding beyond the container width - Keep overflow-x-hidden as safety net - Remove negative-margin sticky footer that extended beyond bounds - Revert to standard DialogFooter without negative margins Co-Authored-By: Paperclip --- ui/src/components/ExecutionWorkspaceCloseDialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index c3ae8858..78c09562 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -88,7 +88,7 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} @@ -252,7 +252,7 @@ export function ExecutionWorkspaceCloseDialog({
{action.label}
{action.description}
{action.command ? ( -
+                      
                         {action.command}
                       
) : null} @@ -291,7 +291,7 @@ export function ExecutionWorkspaceCloseDialog({
) : null} - +
)} -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - + {composerDisabledReason ? ( +
+ {composerDisabledReason}
-
+ ) : ( +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
+ )}
); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d1f16ab7..d16a3103 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -71,8 +71,16 @@ import { SlidersHorizontal, Trash2, } from "lucide-react"; -import type { ActivityEvent } from "@paperclipai/shared"; -import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; +import { + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, + type ActivityEvent, + type Agent, + type FeedbackVote, + type Issue, + type IssueAttachment, + type IssueComment, +} from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { @@ -306,6 +314,12 @@ export function IssueDetail() { enabled: !!issueId, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; + const commentComposerDisabledReason = useMemo(() => { + if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) { + return null; + } + return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); + }, [issue?.currentExecutionWorkspace]); const { data: comments } = useQuery({ queryKey: queryKeys.issues.comments(issueId!), @@ -1522,6 +1536,7 @@ export function IssueDetail() { await interruptQueuedComment.mutateAsync(runId); }} interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null} + composerDisabledReason={commentComposerDisabledReason} onVote={async (commentId, vote, options) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_comment", From 5a9a2a9112a4dd06ec38e047ff8ef6cb4f61a8b6 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 13:12:06 -0500 Subject: [PATCH 07/11] Fix mobile mention menu placement Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.test.tsx | 26 +++++++- ui/src/components/MarkdownEditor.tsx | 76 +++++++++++++++++++++-- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 786b2dee..0df20323 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MarkdownEditor } from "./MarkdownEditor"; +import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor"; const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, @@ -162,4 +162,28 @@ describe("MarkdownEditor", () => { root.unmount(); }); }); + + it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 180, viewportLeft: 120 }, + { offsetLeft: 24, offsetTop: 320, width: 320, height: 260 }, + ), + ).toEqual({ + top: 372, + left: 144, + }); + }); + + it("clamps the mention menu back into view near the viewport edges", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 260, viewportLeft: 240 }, + { offsetLeft: 0, offsetTop: 0, width: 280, height: 220 }, + ), + ).toEqual({ + top: 12, + left: 92, + }); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 24fddcc1..a8fd8e98 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -95,6 +95,17 @@ interface MentionState { endPos: number; } +interface MentionMenuViewport { + offsetLeft: number; + offsetTop: number; + width: number; + height: number; +} + +const MENTION_MENU_WIDTH = 188; +const MENTION_MENU_HEIGHT = 208; +const MENTION_MENU_PADDING = 8; + const CODE_BLOCK_LANGUAGES: Record = { txt: "Text", md: "Markdown", @@ -171,6 +182,40 @@ function detectMention(container: HTMLElement): MentionState | null { }; } +function getMentionMenuViewport(): MentionMenuViewport { + const viewport = window.visualViewport; + if (viewport) { + return { + offsetLeft: viewport.offsetLeft, + offsetTop: viewport.offsetTop, + width: viewport.width, + height: viewport.height, + }; + } + + return { + offsetLeft: 0, + offsetTop: 0, + width: window.innerWidth, + height: window.innerHeight, + }; +} + +export function computeMentionMenuPosition( + anchor: Pick, + viewport: MentionMenuViewport, +) { + const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING; + const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH; + const minTop = viewport.offsetTop + MENTION_MENU_PADDING; + const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT; + + return { + top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)), + left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)), + }; +} + function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { if (!node || !container.contains(node)) return false; const el = node.nodeType === Node.ELEMENT_NODE @@ -416,6 +461,25 @@ export const MarkdownEditor = forwardRef }; }, [checkMention, mentions]); + useEffect(() => { + if (!mentionActive) return; + + const updatePosition = () => requestAnimationFrame(checkMention); + const viewport = window.visualViewport; + + viewport?.addEventListener("resize", updatePosition); + viewport?.addEventListener("scroll", updatePosition); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + viewport?.removeEventListener("resize", updatePosition); + viewport?.removeEventListener("scroll", updatePosition); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [checkMention, mentionActive]); + useEffect(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!editable) return; @@ -526,6 +590,10 @@ export const MarkdownEditor = forwardRef ref.current.insertMarkdown(normalizeMarkdown(rawText)); }, []); + const mentionMenuPosition = mentionState + ? computeMentionMenuPosition(mentionState, getMentionMenuViewport()) + : null; + return (
createPortal(
{filteredMentions.map((option, i) => ( , +})); + +vi.mock("../components/RoutineRunVariablesDialog", () => ({ + RoutineRunVariablesDialog: () => null, + routineRunNeedsConfiguration: () => false, +})); + +vi.mock("../components/RoutineVariablesEditor", () => ({ + RoutineVariablesEditor: () => null, + RoutineVariablesHint: () => null, +})); + +vi.mock("../components/AgentIconPicker", () => ({ + AgentIcon: () => , +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createRoutine(overrides: Partial): RoutineListItem { + return { + id: "routine-1", + companyId: "company-1", + projectId: "project-1", + goalId: null, + parentIssueId: null, + title: "Routine title", + description: null, + assigneeAgentId: "agent-1", + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + triggers: [], + lastRun: null, + activeIssue: null, + ...overrides, + }; +} + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1000", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Routine execution issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1000, + originKind: "routine_execution", + originId: "routine-1", + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + lastActivityAt: new Date("2026-04-01T00:00:00.000Z"), + isUnreadForMe: false, + ...overrides, + }; +} + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +describe("Routines page", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + currentSearch = ""; + navigateMock.mockReset(); + routinesListMock.mockReset(); + issuesListMock.mockReset(); + issuesListRenderMock.mockClear(); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + it("groups routines by project using project names for the section labels", () => { + const groups = buildRoutineGroups( + [ + createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }), + createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2", assigneeAgentId: "agent-2" }), + ], + "project", + new Map([ + ["project-1", { name: "Project Alpha" }], + ["project-2", { name: "Project Beta" }], + ]), + new Map([ + ["agent-1", { name: "Agent One" }], + ["agent-2", { name: "Agent Two" }], + ]), + ); + + expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]); + expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]); + expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]); + }); + + it("shows recent runs through the issues list scoped to routine execution issues", async () => { + currentSearch = "tab=runs"; + routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]); + issuesListMock.mockResolvedValue([ + createIssue({ id: "issue-1", title: "Routine execution A" }), + createIssue({ id: "issue-2", title: "Routine execution B", identifier: "PAP-1001", issueNumber: 1001 }), + ]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" }); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index b58cea8c..85e32796 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -1,19 +1,25 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@/lib/router"; -import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react"; +import { useNavigate, useSearchParams } from "@/lib/router"; +import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react"; import { routinesApi } from "../api/routines"; import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { issuesApi } from "../api/issues"; +import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; +import { groupBy } from "../lib/groupBy"; +import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; +import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; +import { PageTabBar } from "../components/PageTabBar"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; @@ -34,6 +40,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -41,6 +48,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; @@ -71,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) { return enabled ? "active" : "paused"; } +type RoutinesTab = "routines" | "runs"; +type RoutineGroupBy = "none" | "project" | "assignee"; + +type RoutineViewState = { + groupBy: RoutineGroupBy; + collapsedGroups: string[]; +}; + +type RoutineGroup = { + key: string; + label: string | null; + items: RoutineListItem[]; +}; + +const defaultRoutineViewState: RoutineViewState = { + groupBy: "none", + collapsedGroups: [], +}; + +function getRoutineViewState(key: string): RoutineViewState { + try { + const raw = localStorage.getItem(key); + if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) }; + } catch { + // Ignore malformed local state and fall back to defaults. + } + return { ...defaultRoutineViewState }; +} + +function saveRoutineViewState(key: string, state: RoutineViewState) { + localStorage.setItem(key, JSON.stringify(state)); +} + +function formatRoutineRunStatus(value: string | null | undefined) { + if (!value) return null; + return value.replaceAll("_", " "); +} + +export function buildRoutineGroups( + routines: RoutineListItem[], + groupByValue: RoutineGroupBy, + projectById: Map, + agentById: Map, +): RoutineGroup[] { + if (groupByValue === "none") { + return [{ key: "__all", label: null, items: routines }]; + } + + if (groupByValue === "project") { + const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project"); + return Object.keys(groups) + .sort((left, right) => { + const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project"); + const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project"); + return leftLabel.localeCompare(rightLabel); + }) + .map((key) => ({ + key, + label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"), + items: groups[key]!, + })); + } + + const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned"); + return Object.keys(groups) + .sort((left, right) => { + const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent"); + const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent"); + return leftLabel.localeCompare(rightLabel); + }) + .map((key) => ({ + key, + label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"), + items: groups[key]!, + })); +} + +function buildRoutinesTabHref(tab: RoutinesTab) { + return tab === "runs" ? "/routines?tab=runs" : "/routines"; +} + +function RoutineListRow({ + routine, + projectById, + agentById, + runningRoutineId, + statusMutationRoutineId, + onNavigate, + onRunNow, + onToggleEnabled, + onToggleArchived, +}: { + routine: RoutineListItem; + projectById: Map; + agentById: Map; + runningRoutineId: string | null; + statusMutationRoutineId: string | null; + onNavigate: (routineId: string) => void; + onRunNow: (routine: RoutineListItem) => void; + onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void; + onToggleArchived: (routine: RoutineListItem) => void; +}) { + const enabled = routine.status === "active"; + const isArchived = routine.status === "archived"; + const isStatusPending = statusMutationRoutineId === routine.id; + const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; + const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null; + + return ( +
onNavigate(routine.id)} + > +
+
+ {routine.title} + {(isArchived || routine.status === "paused") ? ( + + {isArchived ? "archived" : "paused"} + + ) : null} +
+
+ + + {project?.name ?? "Unknown project"} + + + {agent?.icon ? : null} + {agent?.name ?? "Unknown agent"} + + + {formatLastRunTimestamp(routine.lastRun?.triggeredAt)} + {routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""} + +
+
+ +
event.stopPropagation()}> +
+ onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} + /> + + {isArchived ? "Archived" : enabled ? "On" : "Off"} + +
+ + + + + + + onNavigate(routine.id)}> + Edit + + onRunNow(routine)} + > + {runningRoutineId === routine.id ? "Running..." : "Run now"} + + + onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + > + {enabled ? "Pause" : "Enable"} + + onToggleArchived(routine)} + disabled={isStatusPending} + > + {routine.status === "archived" ? "Restore" : "Archive"} + + + +
+
+ ); +} + export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { pushToast } = useToast(); const descriptionEditorRef = useRef(null); const titleInputRef = useRef(null); @@ -86,6 +286,7 @@ export function Routines() { const [runDialogRoutine, setRunDialogRoutine] = useState(null); const [composerOpen, setComposerOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines"; const [draft, setDraft] = useState<{ title: string; description: string; @@ -105,11 +306,19 @@ export function Routines() { catchUpPolicy: "skip_missed", variables: [], }); + const routineViewStateKey = selectedCompanyId + ? `paperclip:routines-view:${selectedCompanyId}` + : "paperclip:routines-view"; + const [routineViewState, setRoutineViewState] = useState(() => getRoutineViewState(routineViewStateKey)); useEffect(() => { setBreadcrumbs([{ label: "Routines" }]); }, [setBreadcrumbs]); + useEffect(() => { + setRoutineViewState(getRoutineViewState(routineViewStateKey)); + }, [routineViewStateKey]); + const { data: routines, isLoading, error } = useQuery({ queryKey: queryKeys.routines.list(selectedCompanyId!), queryFn: () => routinesApi.list(selectedCompanyId!), @@ -130,6 +339,17 @@ export function Routines() { queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); + const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({ + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"], + queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }), + enabled: !!selectedCompanyId && activeTab === "runs", + }); + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.liveRuns(selectedCompanyId!), + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), + enabled: !!selectedCompanyId && activeTab === "runs", + refetchInterval: 5000, + }); useEffect(() => { autoResizeTextarea(titleInputRef.current); @@ -163,6 +383,13 @@ export function Routines() { navigate(`/routines/${routine.id}?tab=triggers`); }, }); + const updateIssue = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + issuesApi.update(id, data), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] }); + }, + }); const updateRoutineStatus = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }), @@ -250,10 +477,45 @@ export function Routines() { () => new Map((projects ?? []).map((project) => [project.id, project])), [projects], ); + const liveIssueIds = useMemo(() => { + const ids = new Set(); + for (const run of liveRuns ?? []) { + if (run.issueId) ids.add(run.issueId); + } + return ids; + }, [liveRuns]); + const routineGroups = useMemo( + () => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById), + [agentById, projectById, routineViewState.groupBy, routines], + ); + const recentRunsIssueLinkState = useMemo( + () => + createIssueDetailLocationState( + "Recent Runs", + buildRoutinesTabHref("runs"), + "issues", + ), + [], + ); const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null; const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; + function updateRoutineView(patch: Partial) { + setRoutineViewState((current) => { + const next = { ...current, ...patch }; + saveRoutineViewState(routineViewStateKey, next); + return next; + }); + } + + function handleTabChange(tab: string) { + const nextTab = tab === "runs" ? "runs" : "routines"; + startTransition(() => { + navigate(buildRoutinesTabHref(nextTab)); + }); + } + function handleRunNow(routine: RoutineListItem) { const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const needsConfiguration = routineRunNeedsConfiguration({ @@ -268,6 +530,20 @@ export function Routines() { runRoutine.mutate({ id: routine.id, data: {} }); } + function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) { + updateRoutineStatus.mutate({ + id: routine.id, + status: nextRoutineStatus(routine.status, !enabled), + }); + } + + function handleToggleArchived(routine: RoutineListItem) { + updateRoutineStatus.mutate({ + id: routine.id, + status: routine.status === "archived" ? "active" : "archived", + }); + } + if (!selectedCompanyId) { return ; } @@ -294,6 +570,68 @@ export function Routines() {
+ + + +
+

+ {(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"} +

+ + + + + +
+ {([ + ["project", "Project"], + ["assignee", "Agent"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
+
+
+ + updateIssue.mutate({ id, data })} + /> + +
+ { @@ -561,154 +899,64 @@ export function Routines() { ) : null} -
- {(routines ?? []).length === 0 ? ( -
- -
- ) : ( -
- - - - - - - - - - - - {(routines ?? []).map((routine) => { - const enabled = routine.status === "active"; - const isArchived = routine.status === "archived"; - const isStatusPending = statusMutationRoutineId === routine.id; - return ( - navigate(`/routines/${routine.id}`)} - > - - - - - - - - ); - })} - -
NameProjectAgentLast runEnabled -
-
- - {routine.title} - - {(isArchived || routine.status === "paused") && ( -
- {isArchived ? "archived" : "paused"} -
- )} -
-
- {routine.projectId ? ( -
- - {projectById.get(routine.projectId)?.name ?? "Unknown"} -
- ) : ( - - )} -
- {routine.assigneeAgentId ? (() => { - const agent = agentById.get(routine.assigneeAgentId); - return agent ? ( -
- - {agent.name} -
- ) : ( - Unknown - ); - })() : ( - - )} -
-
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
- {routine.lastRun ? ( -
{routine.lastRun.status.replaceAll("_", " ")}
- ) : null} -
e.stopPropagation()}> -
- - updateRoutineStatus.mutate({ - id: routine.id, - status: nextRoutineStatus(routine.status, !enabled), - }) - } - disabled={isStatusPending || isArchived} - aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} - /> - - {isArchived ? "Archived" : enabled ? "On" : "Off"} - -
-
e.stopPropagation()}> - - - - - - navigate(`/routines/${routine.id}`)}> - Edit - - handleRunNow(routine)} - > - {runningRoutineId === routine.id ? "Running..." : "Run now"} - - - - updateRoutineStatus.mutate({ - id: routine.id, - status: enabled ? "paused" : "active", - }) - } - disabled={isStatusPending || isArchived} - > - {enabled ? "Pause" : "Enable"} - - - updateRoutineStatus.mutate({ - id: routine.id, - status: routine.status === "archived" ? "active" : "archived", - }) - } - disabled={isStatusPending} - > - {routine.status === "archived" ? "Restore" : "Archive"} - - - -
-
- )} -
+ {activeTab === "routines" ? ( +
+ {(routines ?? []).length === 0 ? ( +
+ +
+ ) : ( +
+ {routineGroups.map((group) => ( + { + updateRoutineView({ + collapsedGroups: open + ? routineViewState.collapsedGroups.filter((item) => item !== group.key) + : [...routineViewState.collapsedGroups, group.key], + }); + }} + > + {group.label ? ( +
+ + + + {group.label} + + + + {group.items.length} + +
+ ) : null} + + {group.items.map((routine) => ( + navigate(`/routines/${routineId}`)} + onRunNow={handleRunNow} + onToggleEnabled={handleToggleEnabled} + onToggleArchived={handleToggleArchived} + /> + ))} + +
+ ))} +
+ )} +
+ ) : null} Date: Sat, 4 Apr 2026 14:05:08 -0500 Subject: [PATCH 10/11] Avoid blur-save during mention selection Co-Authored-By: Paperclip --- ui/src/components/InlineEditor.test.tsx | 84 +++++++++++++++++++++++++ ui/src/components/InlineEditor.tsx | 63 +++++++++++++++---- 2 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/InlineEditor.test.tsx diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx new file mode 100644 index 00000000..ddf99936 --- /dev/null +++ b/ui/src/components/InlineEditor.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queueContainedBlurCommit } from "./InlineEditor"; + +vi.mock("./MarkdownEditor", () => ({ + MarkdownEditor: () => null, +})); + +vi.mock("../hooks/useAutosaveIndicator", () => ({ + useAutosaveIndicator: () => ({ + state: "idle", + markDirty: () => {}, + reset: () => {}, + runSave: async (save: () => Promise) => { + await save(); + }, + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("queueContainedBlurCommit", () => { + let container: HTMLDivElement; + let inside: HTMLTextAreaElement; + let outside: HTMLButtonElement; + let originalRequestAnimationFrame: typeof window.requestAnimationFrame; + let originalCancelAnimationFrame: typeof window.cancelAnimationFrame; + + beforeEach(() => { + vi.useFakeTimers(); + originalRequestAnimationFrame = window.requestAnimationFrame; + originalCancelAnimationFrame = window.cancelAnimationFrame; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((id: number) => window.clearTimeout(id)) as typeof window.cancelAnimationFrame; + + container = document.createElement("div"); + inside = document.createElement("textarea"); + outside = document.createElement("button"); + container.appendChild(inside); + document.body.append(container, outside); + }); + + afterEach(() => { + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + container.remove(); + outside.remove(); + vi.useRealTimers(); + }); + + async function flushFrames() { + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + } + + it("commits when focus stays outside the editor container", async () => { + const onCommit = vi.fn(); + const cancel = queueContainedBlurCommit(container, onCommit); + + outside.focus(); + await flushFrames(); + + expect(onCommit).toHaveBeenCalledTimes(1); + cancel(); + }); + + it("skips the commit when focus returns inside before the delayed check completes", async () => { + const onCommit = vi.fn(); + const cancel = queueContainedBlurCommit(container, onCommit); + + outside.focus(); + inside.focus(); + await flushFrames(); + + expect(onCommit).not.toHaveBeenCalled(); + cancel(); + }); +}); diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index c05f8a41..43c214df 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -19,6 +19,23 @@ const pad = "px-1 -mx-1"; const markdownPad = "px-1"; const AUTOSAVE_DEBOUNCE_MS = 900; +export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: () => void) { + let frameId = requestAnimationFrame(() => { + frameId = requestAnimationFrame(() => { + frameId = 0; + const active = document.activeElement; + if (active instanceof Node && container.contains(active)) return; + onCommit(); + }); + }); + + return () => { + if (frameId === 0) return; + cancelAnimationFrame(frameId); + frameId = 0; + }; +} + export function InlineEditor({ value, onSave, @@ -35,6 +52,7 @@ export function InlineEditor({ const inputRef = useRef(null); const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); + const blurCommitFrameRef = useRef<(() => void) | null>(null); const { state: autosaveState, markDirty, @@ -52,6 +70,10 @@ export function InlineEditor({ if (autosaveDebounceRef.current) { clearTimeout(autosaveDebounceRef.current); } + if (blurCommitFrameRef.current !== null) { + blurCommitFrameRef.current(); + blurCommitFrameRef.current = null; + } }; }, []); @@ -91,6 +113,30 @@ export function InlineEditor({ } }, [draft, multiline, onSave, value]); + const cancelPendingBlurCommit = useCallback(() => { + if (blurCommitFrameRef.current === null) return; + blurCommitFrameRef.current(); + blurCommitFrameRef.current = null; + }, []); + + const scheduleBlurCommit = useCallback((container: HTMLDivElement) => { + cancelPendingBlurCommit(); + blurCommitFrameRef.current = queueContainedBlurCommit(container, () => { + blurCommitFrameRef.current = null; + if (autosaveDebounceRef.current) { + clearTimeout(autosaveDebounceRef.current); + } + setMultilineFocused(false); + const trimmed = draft.trim(); + if (!trimmed || trimmed === value) { + reset(); + void commit(); + return; + } + void runSave(() => commit()); + }); + }, [cancelPendingBlurCommit, commit, draft, reset, runSave, value]); + function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !multiline) { e.preventDefault(); @@ -146,20 +192,13 @@ export function InlineEditor({ "rounded transition-colors", multilineFocused ? "bg-transparent" : "hover:bg-accent/20", )} - onFocusCapture={() => setMultilineFocused(true)} + onFocusCapture={() => { + cancelPendingBlurCommit(); + setMultilineFocused(true); + }} onBlurCapture={(event) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; - if (autosaveDebounceRef.current) { - clearTimeout(autosaveDebounceRef.current); - } - setMultilineFocused(false); - const trimmed = draft.trim(); - if (!trimmed || trimmed === value) { - reset(); - void commit(); - return; - } - void runSave(() => commit()); + scheduleBlurCommit(event.currentTarget); }} onKeyDown={handleKeyDown} > From 94d4a01b76dd9f05dc8487009217ea8921adfdb7 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 17:00:40 -0500 Subject: [PATCH 11/11] Add skill slash-command autocomplete Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 5 + packages/shared/src/project-mentions.test.ts | 12 ++ packages/shared/src/project-mentions.ts | 57 +++++++++ ui/src/components/MarkdownBody.test.tsx | 8 +- ui/src/components/MarkdownBody.tsx | 4 +- ui/src/components/MarkdownEditor.tsx | 118 +++++++++++++++---- ui/src/context/EditorAutocompleteContext.tsx | 61 ++++++++++ ui/src/lib/mention-chips.ts | 17 ++- ui/src/main.tsx | 37 +++--- 9 files changed, 271 insertions(+), 48 deletions(-) create mode 100644 ui/src/context/EditorAutocompleteContext.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8f35bf12..cc6ea42c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -600,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, + SKILL_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, + buildSkillMentionHref, extractAgentMentionIds, + extractSkillMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseSkillMentionHref, extractProjectMentionIds, type ParsedAgentMention, type ParsedProjectMention, + type ParsedSkillMention, } from "./project-mentions.js"; export { diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 55f27369..5a156959 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import { buildAgentMentionHref, buildProjectMentionHref, + buildSkillMentionHref, extractAgentMentionIds, extractProjectMentionIds, + extractSkillMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseSkillMentionHref, } from "./project-mentions.js"; describe("project-mentions", () => { @@ -26,4 +29,13 @@ describe("project-mentions", () => { }); expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); }); + + it("round-trips skill mentions with slug metadata", () => { + const href = buildSkillMentionHref("skill-123", "release-changelog"); + expect(parseSkillMentionHref(href)).toEqual({ + skillId: "skill-123", + slug: "release-changelog", + }); + expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]); + }); }); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 66be8948..117fad39 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,5 +1,6 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; +export const SKILL_MENTION_SCHEME = "skill://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; @@ -7,7 +8,9 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; +const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; export interface ParsedProjectMention { projectId: string; @@ -19,6 +22,11 @@ export interface ParsedAgentMention { icon: string | null; } +export interface ParsedSkillMention { + skillId: string; + slug: string | null; +} + function normalizeHexColor(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); @@ -103,6 +111,36 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null { }; } +export function buildSkillMentionHref(skillId: string, slug?: string | null): string { + const trimmedSkillId = skillId.trim(); + const normalizedSlug = normalizeSkillSlug(slug ?? null); + if (!normalizedSlug) { + return `${SKILL_MENTION_SCHEME}${trimmedSkillId}`; + } + return `${SKILL_MENTION_SCHEME}${trimmedSkillId}?s=${encodeURIComponent(normalizedSlug)}`; +} + +export function parseSkillMentionHref(href: string): ParsedSkillMention | null { + if (!href.startsWith(SKILL_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "skill:") return null; + + const skillId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!skillId) return null; + + return { + skillId, + slug: normalizeSkillSlug(url.searchParams.get("s") ?? url.searchParams.get("slug")), + }; +} + export function extractProjectMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); @@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] { return [...ids]; } +export function extractSkillMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(SKILL_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseSkillMentionHref(match[1]); + if (parsed) ids.add(parsed.skillId); + } + 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; } + +function normalizeSkillSlug(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().toLowerCase(); + if (!trimmed || !SKILL_SLUG_RE.test(trimmed)) return null; + return trimmed; +} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 5794f989..fa8d46de 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; @@ -30,11 +30,11 @@ describe("MarkdownBody", () => { expect(html).toContain('alt="Org chart"'); }); - it("renders agent and project mentions as chips", () => { + it("renders agent, project, and skill mentions as chips", () => { const html = renderToStaticMarkup( - {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`} + {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} , ); @@ -45,5 +45,7 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/projects/project-456"'); expect(html).toContain('data-mention-kind="project"'); expect(html).toContain("--paperclip-mention-project-color:#336699"); + expect(html).toContain('href="/skills/skill-789"'); + expect(html).toContain('data-mention-kind="skill"'); }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index e00afc84..0a933c66 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -106,7 +106,9 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB if (parsed) { const targetHref = parsed.kind === "project" ? `/projects/${parsed.projectId}` - : `/agents/${parsed.agentId}`; + : parsed.kind === "skill" + ? `/skills/${parsed.skillId}` + : `/agents/${parsed.agentId}`; return ( = 0; i--) { const ch = text[i]; - if (ch === "@") { + if (ch === "@" || ch === "/") { if (i === 0 || /\s/.test(text[i - 1])) { atPos = i; + trigger = ch === "@" ? "mention" : "skill"; + marker = ch; } break; } @@ -171,6 +181,8 @@ function detectMention(container: HTMLElement): MentionState | null { const containerRect = container.getBoundingClientRect(); return { + trigger: trigger ?? "mention", + marker: marker ?? "@", query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, @@ -242,10 +254,18 @@ function mentionMarkdown(option: MentionOption): string { return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; } -/** Replace `@` in the markdown string with the selected mention token. */ -function applyMention(markdown: string, query: string, option: MentionOption): string { - const search = `@${query}`; - const replacement = mentionMarkdown(option); +function skillMarkdown(option: SkillCommandOption): string { + return `[/${option.slug}](${option.href}) `; +} + +function autocompleteMarkdown(option: AutocompleteOption): string { + return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option); +} + +/** Replace the active autocomplete token in the markdown string with the selected token. */ +function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string { + const search = `${state.marker}${state.query}`; + const replacement = autocompleteMarkdown(option); const idx = markdown.lastIndexOf(search); if (idx === -1) return markdown; return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); @@ -265,6 +285,7 @@ export const MarkdownEditor = forwardRef mentions, onSubmit, }: MarkdownEditorProps, forwardedRef) { + const { slashCommands } = useEditorAutocomplete(); const containerRef = useRef(null); const ref = useRef(null); const valueRef = useRef(value); @@ -289,7 +310,10 @@ export const MarkdownEditor = forwardRef const [mentionState, setMentionState] = useState(null); const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); - const mentionActive = mentionState !== null && mentions && mentions.length > 0; + const mentionActive = mentionState !== null && ( + (mentionState.trigger === "mention" && Boolean(mentions?.length)) + || (mentionState.trigger === "skill" && slashCommands.length > 0) + ); const mentionOptionByKey = useMemo(() => { const map = new Map(); for (const mention of mentions ?? []) { @@ -304,11 +328,20 @@ export const MarkdownEditor = forwardRef return map; }, [mentions]); - const filteredMentions = useMemo(() => { - if (!mentionState || !mentions) return []; - const q = mentionState.query.toLowerCase(); + const filteredMentions = useMemo(() => { + if (!mentionState) return []; + const q = mentionState.query.trim().toLowerCase(); + if (mentionState.trigger === "skill") { + return slashCommands + .filter((command) => { + if (!q) return true; + return command.aliases.some((alias) => alias.toLowerCase().includes(q)); + }) + .slice(0, 8); + } + if (!mentions) return []; return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8); - }, [mentionState?.query, mentions]); + }, [mentionState, mentions, slashCommands]); const setEditorRef = useCallback((instance: MDXEditorMethods | null) => { ref.current = instance; @@ -420,6 +453,11 @@ export const MarkdownEditor = forwardRef continue; } + if (parsed.kind === "skill") { + applyMentionChipDecoration(link, parsed); + continue; + } + const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); applyMentionChipDecoration(link, { ...parsed, @@ -430,12 +468,30 @@ export const MarkdownEditor = forwardRef // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { - if (!mentions || mentions.length === 0 || !containerRef.current) { + if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) { mentionStateRef.current = null; setMentionState(null); return; } const result = detectMention(containerRef.current); + if ( + result + && result.trigger === "mention" + && (!mentions || mentions.length === 0) + ) { + mentionStateRef.current = null; + setMentionState(null); + return; + } + if ( + result + && result.trigger === "skill" + && slashCommands.length === 0 + ) { + mentionStateRef.current = null; + setMentionState(null); + return; + } mentionStateRef.current = result; if (result) { setMentionState(result); @@ -443,10 +499,10 @@ export const MarkdownEditor = forwardRef } else { setMentionState(null); } - }, [mentions]); + }, [mentions, slashCommands.length]); useEffect(() => { - if (!mentions || mentions.length === 0) return; + if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return; const el = containerRef.current; // Listen for input events on the container so mention detection @@ -459,7 +515,7 @@ export const MarkdownEditor = forwardRef document.removeEventListener("selectionchange", checkMention); el?.removeEventListener("input", onInput, true); }; - }, [checkMention, mentions]); + }, [checkMention, mentions, slashCommands.length]); useEffect(() => { if (!mentionActive) return; @@ -496,13 +552,13 @@ export const MarkdownEditor = forwardRef }, [decorateProjectMentions, value]); const selectMention = useCallback( - (option: MentionOption) => { + (option: AutocompleteOption) => { // Read from ref to avoid stale-closure issues (selectionchange can // update state between the last render and this callback firing). const state = mentionStateRef.current; if (!state) return; const current = latestValueRef.current; - const next = applyMention(current, state.query, option); + const next = applyMention(current, state, option); if (next !== current) { latestValueRef.current = next; echoIgnoreMarkdownRef.current = next; @@ -517,17 +573,20 @@ export const MarkdownEditor = forwardRef 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 mentionHref = option.kind === "skill" + ? option.href + : option.kind === "project" && option.projectId + ? buildProjectMentionHref(option.projectId, option.projectColor ?? null) + : buildAgentMentionHref( + option.agentId ?? option.id.replace(/^agent:/, ""), + option.agentIcon ?? null, + ); + const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`; 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}`; + return href === mentionHref && link.textContent === expectedLabel; }); const containerRect = containerRef.current?.getBoundingClientRect(); const target = matchingMentions.sort((a, b) => { @@ -729,7 +788,9 @@ export const MarkdownEditor = forwardRef }} onMouseEnter={() => setMentionIndex(i)} > - {option.kind === "project" && option.projectId ? ( + {option.kind === "skill" ? ( + + ) : option.kind === "project" && option.projectId ? ( className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> )} - {option.name} + {option.kind === "skill" ? `/${option.slug}` : option.name} {option.kind === "project" && option.projectId && ( Project )} + {option.kind === "skill" && ( + + Skill + + )} ))}
, diff --git a/ui/src/context/EditorAutocompleteContext.tsx b/ui/src/context/EditorAutocompleteContext.tsx new file mode 100644 index 00000000..8d6c0004 --- /dev/null +++ b/ui/src/context/EditorAutocompleteContext.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { buildSkillMentionHref } from "@paperclipai/shared"; +import { companySkillsApi } from "../api/companySkills"; +import { useCompany } from "./CompanyContext"; +import { queryKeys } from "../lib/queryKeys"; + +export interface SkillCommandOption { + id: string; + kind: "skill"; + skillId: string; + key: string; + name: string; + slug: string; + description: string | null; + href: string; + aliases: string[]; +} + +interface EditorAutocompleteContextValue { + slashCommands: SkillCommandOption[]; +} + +const EditorAutocompleteContext = createContext({ + slashCommands: [], +}); + +export function EditorAutocompleteProvider({ children }: { children: ReactNode }) { + const { selectedCompanyId } = useCompany(); + const { data: companySkills = [] } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.companySkills.list(selectedCompanyId) + : ["company-skills", "__none__"], + queryFn: () => companySkillsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + const value = useMemo(() => ({ + slashCommands: companySkills.map((skill) => ({ + id: `skill:${skill.id}`, + kind: "skill", + skillId: skill.id, + key: skill.key, + name: skill.name, + slug: skill.slug, + description: skill.description ?? null, + href: buildSkillMentionHref(skill.id, skill.slug), + aliases: [skill.slug, skill.name, skill.key], + })), + }), [companySkills]); + + return ( + + {children} + + ); +} + +export function useEditorAutocomplete() { + return useContext(EditorAutocompleteContext); +} diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index fe043100..7fecbb89 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; +import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared"; import { getAgentIcon } from "./agent-icons"; import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; @@ -13,6 +13,11 @@ export type ParsedMentionChip = kind: "project"; projectId: string; color: string | null; + } + | { + kind: "skill"; + skillId: string; + slug: string | null; }; const iconMaskCache = new Map(); @@ -36,6 +41,15 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null { }; } + const skill = parseSkillMentionHref(href); + if (skill) { + return { + kind: "skill", + skillId: skill.skillId, + slug: skill.slug, + }; + } + return null; } @@ -86,6 +100,7 @@ export function clearMentionChipDecoration(element: HTMLElement) { "paperclip-mention-chip", "paperclip-mention-chip--agent", "paperclip-mention-chip--project", + "paperclip-mention-chip--skill", "paperclip-project-mention-chip", ); element.removeAttribute("contenteditable"); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 1292810d..e0efe15e 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -11,6 +11,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext"; import { PanelProvider } from "./context/PanelContext"; import { SidebarProvider } from "./context/SidebarContext"; import { DialogProvider } from "./context/DialogContext"; +import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext"; import { ToastProvider } from "./context/ToastContext"; import { ThemeProvider } from "./context/ThemeContext"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -42,23 +43,25 @@ createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +