mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
## Thinking Path > - Paperclip orchestrates AI agents through a company-scoped control plane. > - The affected surface is the board UI for issue threads, issue lists, routines, dialogs, navigation, and issue review indicators. > - Closed PR #4692 bundled backend, schema, docs, workflow, and UI/QoL work into one oversized change set. > - Greptile could not keep reviewing that broad PR because it exceeded the 100-file review limit and mixed unrelated concerns. > - This pull request extracts the UI/QoL slice into a fresh branch under the review limit while leaving workflow and lockfile churn out. > - The benefit is a focused review path for the board UI performance and workflow improvements without reopening the oversized PR. ## What Changed - Added long issue-thread virtualization, scroll-container binding, anchor preservation, latest-comment jump targeting, and related regression/perf fixtures. - Improved issue list scalability with scroll-based loading, server offset parameters, and pagination-focused UI tests. - Reduced new issue dialog typing churn and split dialog action subscriptions so broad layout/nav surfaces avoid unnecessary renders. - Added routine variables help and routine description mention options for users, agents, and projects. - Added productivity review badge/link UI and fixed the badge to use Paperclip's company-prefixed router link. - Kept the split PR below Greptile's review limit and excluded `.github/workflows/pr.yml` and `pnpm-lock.yaml`. ## Verification - `pnpm install --no-frozen-lockfile` in the clean worktree to install `@tanstack/react-virtual` locally without committing lockfile churn. - `pnpm --filter @paperclipai/ui exec vitest run --config vitest.config.ts src/components/IssueChatThread.test.tsx src/components/IssuesList.test.tsx src/components/NewIssueDialog.test.tsx src/pages/Routines.test.tsx src/pages/Issues.test.tsx` passed: 5 files, 83 tests. - `pnpm --filter @paperclipai/ui typecheck` passed. - `git diff --check origin/master..HEAD` passed. - Split-scope checks: 53 changed files; no `.github/workflows/pr.yml`; no `pnpm-lock.yaml`. - Screenshots were not captured in this heartbeat; the changes are primarily virtualization, routing, pagination, and editor behavior covered by focused regression tests. ## Risks - Moderate UI risk because issue-thread virtualization changes scroll behavior on long conversations; regression tests cover anchor jumps, latest-comment targeting, row metadata, and short-thread fallback. - Moderate integration risk because the issue-list offset parameter and productivity review field depend on matching API behavior. - Dependency risk: the UI package adds `@tanstack/react-virtual` while repository policy keeps `pnpm-lock.yaml` out of PRs, so CI must resolve dependency changes through the repo's normal lockfile policy. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled local repository and GitHub workflow. Exact runtime context window was not exposed by the harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
1991ec9d6f
commit
6b7f6ce4b8
48 changed files with 3388 additions and 260 deletions
|
|
@ -19,7 +19,7 @@ import { usePanel } from "../context/PanelContext";
|
|||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
|
|
@ -626,7 +626,7 @@ export function AgentDetail() {
|
|||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { closePanel } = usePanel();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
|
@ -55,7 +55,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean
|
|||
|
||||
export function Agents() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialog();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
|
@ -36,7 +36,7 @@ export function Companies() {
|
|||
loading,
|
||||
error,
|
||||
} = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { agentsApi } from "../api/agents";
|
|||
import { projectsApi } from "../api/projects";
|
||||
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
|
|
@ -36,7 +36,7 @@ function getRecentIssues(issues: Issue[]): Issue[] {
|
|||
|
||||
export function Dashboard() {
|
||||
const { selectedCompanyId, companies } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { projectsApi } from "../api/projects";
|
|||
import { assetsApi } from "../api/assets";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { GoalProperties } from "../components/GoalProperties";
|
||||
|
|
@ -49,7 +49,7 @@ export function GoalPropertiesToggleButton({
|
|||
export function GoalDetail() {
|
||||
const { goalId } = useParams<{ goalId: string }>();
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { openNewGoal } = useDialogActions();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { GoalTree } from "../components/GoalTree";
|
||||
|
|
@ -13,7 +13,7 @@ import { Target, Plus } from "lucide-react";
|
|||
|
||||
export function Goals() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { openNewGoal } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
213
ui/src/pages/IssueChatLongThreadPerf.tsx
Normal file
213
ui/src/pages/IssueChatLongThreadPerf.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { Profiler, useEffect, useLayoutEffect, useMemo, useRef, useState, type ProfilerOnRenderCallback } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IssueChatThread } from "../components/IssueChatThread";
|
||||
import {
|
||||
issueChatLongThreadAgentMap,
|
||||
issueChatLongThreadComments,
|
||||
issueChatLongThreadEvents,
|
||||
issueChatLongThreadFixtureContext,
|
||||
issueChatLongThreadLinkedRuns,
|
||||
issueChatLongThreadLiveRuns,
|
||||
issueChatLongThreadMarkdownCommentIds,
|
||||
issueChatLongThreadTranscriptsByRunId,
|
||||
LONG_THREAD_COMMENT_COUNT,
|
||||
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||
} from "../fixtures/issueChatLongThreadFixture";
|
||||
|
||||
const noop = async () => {};
|
||||
|
||||
type RenderMetrics = {
|
||||
commitCount: number;
|
||||
mountActualDuration: number | null;
|
||||
latestActualDuration: number | null;
|
||||
maxActualDuration: number;
|
||||
totalActualDuration: number;
|
||||
};
|
||||
|
||||
const initialMetrics: RenderMetrics = {
|
||||
commitCount: 0,
|
||||
mountActualDuration: null,
|
||||
latestActualDuration: null,
|
||||
maxActualDuration: 0,
|
||||
totalActualDuration: 0,
|
||||
};
|
||||
|
||||
function formatMs(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value)) return "pending";
|
||||
return `${value.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
function MetricTile({ label, value, testId }: { label: string; value: string; testId: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background px-3 py-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div data-testid={testId} className="mt-1 font-mono text-sm text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueChatLongThreadPerf() {
|
||||
const [metrics, setMetrics] = useState<RenderMetrics>(initialMetrics);
|
||||
const metricsRef = useRef<RenderMetrics>(initialMetrics);
|
||||
const renderStartedAtRef = useRef(performance.now());
|
||||
const publishTimerRef = useRef<number | null>(null);
|
||||
const publishedRef = useRef(false);
|
||||
const fixture = issueChatLongThreadFixtureContext;
|
||||
const rowTarget = useMemo(
|
||||
() => LONG_THREAD_COMMENT_COUNT + issueChatLongThreadEvents.length + issueChatLongThreadLinkedRuns.length,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (publishTimerRef.current !== null) {
|
||||
window.clearTimeout(publishTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (publishedRef.current || metricsRef.current.commitCount > 0) return;
|
||||
const mountDuration = performance.now() - renderStartedAtRef.current;
|
||||
const next = {
|
||||
commitCount: 1,
|
||||
mountActualDuration: mountDuration,
|
||||
latestActualDuration: mountDuration,
|
||||
maxActualDuration: mountDuration,
|
||||
totalActualDuration: mountDuration,
|
||||
};
|
||||
metricsRef.current = next;
|
||||
publishedRef.current = true;
|
||||
setMetrics(next);
|
||||
}, []);
|
||||
|
||||
const handleRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
|
||||
const current = metricsRef.current;
|
||||
metricsRef.current = {
|
||||
commitCount: current.commitCount + 1,
|
||||
mountActualDuration: phase === "mount" && current.mountActualDuration === null
|
||||
? actualDuration
|
||||
: current.mountActualDuration,
|
||||
latestActualDuration: actualDuration,
|
||||
maxActualDuration: Math.max(current.maxActualDuration, actualDuration),
|
||||
totalActualDuration: current.totalActualDuration + actualDuration,
|
||||
};
|
||||
|
||||
if (publishedRef.current || publishTimerRef.current !== null) return;
|
||||
publishTimerRef.current = window.setTimeout(() => {
|
||||
publishTimerRef.current = null;
|
||||
publishedRef.current = true;
|
||||
setMetrics(metricsRef.current);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="issue-chat-long-thread-perf" className="space-y-5">
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-[11px]">
|
||||
{fixture.issue.identifier}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{fixture.issue.status.replace(/_/g, " ")}</Badge>
|
||||
<Badge variant="outline">{fixture.issue.projectName}</Badge>
|
||||
</div>
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{fixture.issue.title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Deterministic local fixture for measuring the current direct-render issue chat path with
|
||||
hundreds of merged thread rows, markdown-heavy assistant bodies, linked runs, documents,
|
||||
sub-issues, and sidebar context.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid min-w-[280px] grid-cols-2 gap-2">
|
||||
<MetricTile label="Fixture rows" value={String(rowTarget)} testId="perf-fixture-row-target" />
|
||||
<MetricTile label="Markdown rows" value={String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)} testId="perf-fixture-markdown-rows" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<main className="min-w-0 space-y-4">
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Issue documents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 sm:grid-cols-2">
|
||||
{fixture.documents.map((document) => (
|
||||
<div key={document} className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
{document}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Sub-issues</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{fixture.subIssues.map((subIssue, index) => (
|
||||
<div key={subIssue} className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
<span className="font-mono text-xs text-muted-foreground">#{index + 1}</span>
|
||||
<span>{subIssue}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Profiler id="issue-chat-long-thread" onRender={handleRender}>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={issueChatLongThreadLiveRuns}
|
||||
issueStatus="in_progress"
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={noop}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</Profiler>
|
||||
</main>
|
||||
|
||||
<aside className="space-y-4 xl:sticky xl:top-4 xl:self-start">
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Baseline metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<MetricTile label="Profiler commits" value={String(metrics.commitCount)} testId="perf-commit-count" />
|
||||
<MetricTile label="Mount duration" value={formatMs(metrics.mountActualDuration)} testId="perf-mount-duration" />
|
||||
<MetricTile label="Latest duration" value={formatMs(metrics.latestActualDuration)} testId="perf-latest-duration" />
|
||||
<MetricTile label="Max duration" value={formatMs(metrics.maxActualDuration)} testId="perf-max-duration" />
|
||||
<MetricTile label="Total duration" value={formatMs(metrics.totalActualDuration)} testId="perf-total-duration" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Fixture shape</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{fixture.sidebarStats.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="hidden" data-testid="perf-markdown-comment-id-sample">
|
||||
{[...issueChatLongThreadMarkdownCommentIds].slice(0, 3).join(",")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -134,6 +134,9 @@ vi.mock("../context/DialogContext", () => ({
|
|||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents";
|
|||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
|
|
@ -77,6 +77,7 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
|||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
|
|
@ -108,6 +109,7 @@ import {
|
|||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
|
|
@ -558,6 +560,7 @@ type IssueDetailChatTabProps = {
|
|||
hasOlderComments: boolean;
|
||||
commentsLoadingOlder: boolean;
|
||||
onLoadOlderComments: () => void;
|
||||
onRefreshLatestComments: () => Promise<unknown> | void;
|
||||
composerRef: Ref<IssueChatComposerHandle>;
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
||||
|
|
@ -611,6 +614,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
hasOlderComments,
|
||||
commentsLoadingOlder,
|
||||
onLoadOlderComments,
|
||||
onRefreshLatestComments,
|
||||
composerRef,
|
||||
feedbackVotes,
|
||||
feedbackDataSharingPreference,
|
||||
|
|
@ -835,6 +839,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
}
|
||||
: undefined}
|
||||
onImageClick={onImageClick}
|
||||
onRefreshLatestComments={onRefreshLatestComments}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1023,7 +1028,7 @@ function IssueDetailActivityTab({
|
|||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -1090,6 +1095,7 @@ export function IssueDetail() {
|
|||
isFetchingNextPage: commentsLoadingOlder,
|
||||
hasNextPage: hasOlderComments,
|
||||
fetchNextPage: fetchOlderComments,
|
||||
refetch: refetchComments,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.issues.comments(issueId!),
|
||||
queryFn: ({ pageParam }) =>
|
||||
|
|
@ -2554,6 +2560,13 @@ export function IssueDetail() {
|
|||
const loadOlderComments = useCallback(() => {
|
||||
void fetchOlderComments();
|
||||
}, [fetchOlderComments]);
|
||||
const refetchLatestComments = useCallback(async () => {
|
||||
// Refetch the entire infinite-query (page 0 first), so any comments that
|
||||
// arrived after the initial load — including ones live updates may have
|
||||
// missed during reconnects — are present before we scroll the user to
|
||||
// the absolute newest.
|
||||
await refetchComments();
|
||||
}, [refetchComments]);
|
||||
useEffect(() => {
|
||||
if (!shouldPrefetchOlderComments) return;
|
||||
void fetchOlderComments();
|
||||
|
|
@ -2906,6 +2919,20 @@ export function IssueDetail() {
|
|||
</Link>
|
||||
)}
|
||||
|
||||
{issue.productivityReview ? (
|
||||
<ProductivityReviewBadge review={issue.productivityReview} />
|
||||
) : null}
|
||||
|
||||
{issue.originKind === "issue_productivity_review" ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
|
||||
title="This task is a productivity review."
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
Productivity review
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{issue.projectId ? (
|
||||
<Link
|
||||
to={`/projects/${issue.projectId}`}
|
||||
|
|
@ -3402,6 +3429,7 @@ export function IssueDetail() {
|
|||
hasOlderComments={hasOlderComments}
|
||||
commentsLoadingOlder={commentsLoadingOlder}
|
||||
onLoadOlderComments={loadOlderComments}
|
||||
onRefreshLatestComments={refetchLatestComments}
|
||||
composerRef={commentComposerRef}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildIssuesSearchUrl } from "./Issues";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { buildIssuesSearchUrl, getNextIssuesPageOffset, mergeIssuePagesStable } from "./Issues";
|
||||
|
||||
function createIssue(id: string, title: string): Issue {
|
||||
return { id, title } as Issue;
|
||||
}
|
||||
|
||||
describe("buildIssuesSearchUrl", () => {
|
||||
it("preserves trailing spaces in the synced search param", () => {
|
||||
|
|
@ -14,3 +19,29 @@ describe("buildIssuesSearchUrl", () => {
|
|||
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issues page pagination helpers", () => {
|
||||
it("advances to the next offset when the current page is full", () => {
|
||||
expect(getNextIssuesPageOffset(500, 0)).toBe(500);
|
||||
expect(getNextIssuesPageOffset(500, 500)).toBe(1000);
|
||||
expect(getNextIssuesPageOffset(1000, 2000, 1000)).toBe(3000);
|
||||
});
|
||||
|
||||
it("stops requesting issue pages when the current page is partial", () => {
|
||||
expect(getNextIssuesPageOffset(499, 0)).toBeUndefined();
|
||||
expect(getNextIssuesPageOffset(999, 2000, 1000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dedupes overlapping pages without moving the original issue position", () => {
|
||||
const first = createIssue("issue-1", "Original first");
|
||||
const second = createIssue("issue-2", "Second");
|
||||
const duplicateFirst = createIssue("issue-1", "Duplicate first");
|
||||
const third = createIssue("issue-3", "Third");
|
||||
|
||||
expect(mergeIssuePagesStable([[first, second], [duplicateFirst, third]])).toEqual([
|
||||
first,
|
||||
second,
|
||||
third,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
|
|
@ -13,8 +13,33 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
|||
import { EmptyState } from "../components/EmptyState";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { CircleDot } from "lucide-react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
|
||||
const ISSUES_PAGE_SIZE = 500;
|
||||
|
||||
export function getNextIssuesPageOffset(
|
||||
loadedPageSize: number,
|
||||
currentOffset: number,
|
||||
pageSize: number = ISSUES_PAGE_SIZE,
|
||||
): number | undefined {
|
||||
return loadedPageSize >= pageSize ? currentOffset + pageSize : undefined;
|
||||
}
|
||||
|
||||
export function mergeIssuePagesStable(pages: Issue[][]): Issue[] {
|
||||
const seenIssueIds = new Set<string>();
|
||||
const merged: Issue[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const issue of page) {
|
||||
if (seenIssueIds.has(issue.id)) continue;
|
||||
seenIssueIds.add(issue.id);
|
||||
merged.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function buildIssuesSearchUrl(currentHref: string, search: string): string | null {
|
||||
const url = new URL(currentHref);
|
||||
|
|
@ -36,15 +61,27 @@ export function Issues() {
|
|||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const fetchNextPageInFlightRef = useRef(false);
|
||||
|
||||
const initialSearch = searchParams.get("q") ?? "";
|
||||
const urlSearch = searchParams.get("q") ?? "";
|
||||
const [searchOverride, setSearchOverride] = useState<{ search: string; locationSearch: string } | null>(null);
|
||||
const syncedSearch = useMemo(() => {
|
||||
if (typeof window !== "undefined" && searchOverride?.locationSearch === window.location.search) {
|
||||
return searchOverride.search;
|
||||
}
|
||||
return urlSearch;
|
||||
}, [searchOverride, urlSearch, location.search]);
|
||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
|
||||
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
|
||||
if (!nextUrl) return;
|
||||
if (!nextUrl) {
|
||||
setSearchOverride(null);
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
setSearchOverride({ search, locationSearch: window.location.search });
|
||||
}, []);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
|
|
@ -82,7 +119,16 @@ export function Issues() {
|
|||
setBreadcrumbs([{ label: "Issues" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
const issuePageSize = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : ISSUES_PAGE_SIZE;
|
||||
|
||||
const {
|
||||
data: issuePages,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [
|
||||
...queryKeys.issues.list(selectedCompanyId!),
|
||||
"participant-agent",
|
||||
|
|
@ -90,16 +136,34 @@ export function Issues() {
|
|||
"workspace",
|
||||
workspaceIdFilter ?? "__all__",
|
||||
"with-routine-executions",
|
||||
"infinite",
|
||||
issuePageSize,
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, {
|
||||
queryFn: ({ pageParam }) => issuesApi.list(selectedCompanyId!, {
|
||||
participantAgentId,
|
||||
workspaceId: workspaceIdFilter,
|
||||
includeRoutineExecutions: true,
|
||||
...(workspaceIdFilter ? { limit: WORKSPACE_FILTER_ISSUE_LIMIT } : {}),
|
||||
limit: issuePageSize,
|
||||
offset: pageParam,
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
|
||||
getNextIssuesPageOffset(lastPage.length, lastPageParam, issuePageSize),
|
||||
enabled: !!selectedCompanyId,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const issues = useMemo(() => mergeIssuePagesStable(issuePages?.pages ?? []), [issuePages]);
|
||||
const hasMoreServerIssues = syncedSearch.trim().length === 0
|
||||
&& hasNextPage === true;
|
||||
const loadMoreServerIssues = useCallback(() => {
|
||||
if (!hasNextPage || isFetchingNextPage || fetchNextPageInFlightRef.current) return;
|
||||
fetchNextPageInFlightRef.current = true;
|
||||
void fetchNextPage({ cancelRefetch: false }).finally(() => {
|
||||
fetchNextPageInFlightRef.current = false;
|
||||
});
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
|
|
@ -116,6 +180,7 @@ export function Issues() {
|
|||
<IssuesList
|
||||
issues={issues ?? []}
|
||||
isLoading={isLoading}
|
||||
isLoadingMoreIssues={isFetchingNextPage}
|
||||
error={error as Error | null}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
|
|
@ -124,9 +189,11 @@ export function Issues() {
|
|||
issueLinkState={issueLinkState}
|
||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||
initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}
|
||||
initialSearch={initialSearch}
|
||||
initialSearch={syncedSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
enableRoutineVisibilityFilter
|
||||
hasMoreIssues={hasMoreServerIssues}
|
||||
onLoadMoreIssues={loadMoreServerIssues}
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
|
|
@ -15,7 +15,7 @@ import { Hexagon, Plus } from "lucide-react";
|
|||
|
||||
export function Projects() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialog();
|
||||
const { openNewProject } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -20,18 +20,20 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
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";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
|
||||
import {
|
||||
RoutineRunVariablesDialog,
|
||||
type RoutineRunDialogSubmitData,
|
||||
|
|
@ -350,6 +352,11 @@ export function RoutineDetail() {
|
|||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const routineDefaults = useMemo(
|
||||
() =>
|
||||
|
|
@ -649,6 +656,13 @@ export function RoutineDetail() {
|
|||
})),
|
||||
[projects],
|
||||
);
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
projects,
|
||||
members: companyMembers?.users,
|
||||
});
|
||||
}, [agents, companyMembers?.users, projects]);
|
||||
const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null;
|
||||
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
|
||||
|
||||
|
|
@ -882,6 +896,7 @@ export function RoutineDetail() {
|
|||
placeholder="Add instructions..."
|
||||
bordered={false}
|
||||
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||
mentions={mentionOptions}
|
||||
onSubmit={() => {
|
||||
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||
saveRoutine.mutate();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ let currentSearch = "";
|
|||
const navigateMock = vi.fn();
|
||||
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
|
||||
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
|
||||
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
|
||||
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
|
||||
<div data-testid="issues-list">{issues.map((issue) => issue.title).join(", ")}</div>
|
||||
));
|
||||
|
|
@ -158,6 +159,24 @@ vi.mock("../api/projects", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/access", () => ({
|
||||
accessApi: {
|
||||
listUserDirectory: vi.fn(async () => ({
|
||||
users: [
|
||||
{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: {
|
||||
name: "Taylor",
|
||||
email: "taylor@example.com",
|
||||
image: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: {
|
||||
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })),
|
||||
|
|
@ -186,7 +205,10 @@ vi.mock("@/components/ui/tabs", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../components/MarkdownEditor", () => ({
|
||||
MarkdownEditor: () => <div />,
|
||||
MarkdownEditor: (props: { mentions?: Array<{ id: string; name: string }> }) => {
|
||||
markdownEditorRenderMock(props);
|
||||
return <div data-testid="markdown-editor" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/InlineEntitySelector", () => ({
|
||||
|
|
@ -303,6 +325,7 @@ describe("Routines page", () => {
|
|||
navigateMock.mockReset();
|
||||
routinesListMock.mockReset();
|
||||
issuesListMock.mockReset();
|
||||
markdownEditorRenderMock.mockClear();
|
||||
issuesListRenderMock.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
|
@ -334,6 +357,70 @@ describe("Routines page", () => {
|
|||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
|
||||
});
|
||||
|
||||
it("passes company mention options to the routine description editor", async () => {
|
||||
routinesListMock.mockResolvedValue([]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
let createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Create routine"),
|
||||
);
|
||||
for (let attempts = 0; attempts < 5 && !createButton; attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Create routine"),
|
||||
);
|
||||
}
|
||||
|
||||
expect(createButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
createButton?.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5; attempts += 1) {
|
||||
const hasMentionOptions = markdownEditorRenderMock.mock.calls.some(([props]) => (props.mentions ?? []).length > 0);
|
||||
if (hasMentionOptions) break;
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const callsWithMentions = markdownEditorRenderMock.mock.calls
|
||||
.map(([props]) => props.mentions ?? [])
|
||||
.filter((mentions) => mentions.length > 0);
|
||||
|
||||
expect(callsWithMentions.at(-1)?.map((mention) => mention.id)).toEqual([
|
||||
"user:user-1",
|
||||
"agent:agent-1",
|
||||
"agent:agent-2",
|
||||
"project:project-1",
|
||||
"project:project-2",
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows recent runs through the issues list scoped to routine execution issues", async () => {
|
||||
currentSearch = "tab=runs";
|
||||
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import { agentsApi } from "../api/agents";
|
|||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
|
|
@ -23,7 +25,7 @@ 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";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
|
||||
import {
|
||||
RoutineRunVariablesDialog,
|
||||
type RoutineRunDialogSubmitData,
|
||||
|
|
@ -353,6 +355,11 @@ export function Routines() {
|
|||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
|
||||
|
|
@ -369,6 +376,14 @@ export function Routines() {
|
|||
autoResizeTextarea(titleInputRef.current);
|
||||
}, [draft.title, composerOpen]);
|
||||
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
projects,
|
||||
members: companyMembers?.users,
|
||||
});
|
||||
}, [agents, companyMembers?.users, projects]);
|
||||
|
||||
const createRoutine = useMutation({
|
||||
mutationFn: () =>
|
||||
routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
|
||||
|
|
@ -808,6 +823,7 @@ export function Routines() {
|
|||
placeholder="Add instructions..."
|
||||
bordered={false}
|
||||
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||
mentions={mentionOptions}
|
||||
onSubmit={() => {
|
||||
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
||||
createRoutine.mutate();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue