diff --git a/packages/db/src/migrations/0109_routine_description_annotations.sql b/packages/db/src/migrations/0109_routine_description_annotations.sql index 9c34612b618..adc17b8ab1c 100644 --- a/packages/db/src/migrations/0109_routine_description_annotations.sql +++ b/packages/db/src/migrations/0109_routine_description_annotations.sql @@ -62,6 +62,22 @@ DO $$ BEGIN END IF; END $$; --> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_threads_owner_check' + ) THEN + ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_owner_check" CHECK ("issue_id" IS NOT NULL OR "routine_id" IS NOT NULL); + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_comments_owner_check' + ) THEN + ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_owner_check" CHECK ("issue_id" IS NOT NULL OR "routine_id" IS NOT NULL); + END IF; +END $$; +--> statement-breakpoint CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_routine_status_idx" ON "document_annotation_threads" USING btree ("company_id","routine_id","status"); --> statement-breakpoint CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_routine_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","routine_id","created_at"); diff --git a/packages/db/src/schema/document_annotation_comments.ts b/packages/db/src/schema/document_annotation_comments.ts index 05214e9b021..afa8069e431 100644 --- a/packages/db/src/schema/document_annotation_comments.ts +++ b/packages/db/src/schema/document_annotation_comments.ts @@ -1,5 +1,6 @@ import type { IssueCommentAuthorType } from "@paperclipai/shared"; -import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { agents } from "./agents.js"; import { companies } from "./companies.js"; import { documentAnnotationThreads } from "./document_annotation_threads.js"; @@ -50,5 +51,9 @@ export const documentAnnotationComments = pgTable( ), issueCommentIdx: index("document_annotation_comments_issue_comment_idx").on(table.issueCommentId), bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")), + ownerCheck: check( + "document_annotation_comments_owner_check", + sql`${table.issueId} IS NOT NULL OR ${table.routineId} IS NOT NULL`, + ), }), ); diff --git a/packages/db/src/schema/document_annotation_threads.ts b/packages/db/src/schema/document_annotation_threads.ts index a856c647b41..2d24751c8fa 100644 --- a/packages/db/src/schema/document_annotation_threads.ts +++ b/packages/db/src/schema/document_annotation_threads.ts @@ -4,7 +4,8 @@ import type { DocumentAnnotationAnchorState, DocumentAnnotationThreadStatus, } from "@paperclipai/shared"; -import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { check, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { agents } from "./agents.js"; import { companies } from "./companies.js"; import { documentRevisions } from "./document_revisions.js"; @@ -73,5 +74,9 @@ export const documentAnnotationThreads = pgTable( table.companyId, table.anchorState, ), + ownerCheck: check( + "document_annotation_threads_owner_check", + sql`${table.issueId} IS NOT NULL OR ${table.routineId} IS NOT NULL`, + ), }), ); diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index 885d6abc8fe..f574a2cb509 100644 --- a/ui/src/components/routine-sections/editable-sections.tsx +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -78,7 +78,11 @@ const signingModeDescriptions: Record = { }; const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]); -export function OverviewSection() { +export function OverviewSection({ + defaultDescriptionAnnotationsOpen = false, +}: { + defaultDescriptionAnnotationsOpen?: boolean; +} = {}) { const ctx = useRoutineDetail(); const { routine, @@ -103,7 +107,7 @@ export function OverviewSection() { isSectionDirty, navigateToSection, } = ctx; - const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(false); + const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen); const activeTriggers = routine.triggers.length; const nextFire = useMemo(() => { diff --git a/ui/storybook/stories/routine-detail-c.stories.tsx b/ui/storybook/stories/routine-detail-c.stories.tsx index 4b797e0d71a..32ce38ba970 100644 --- a/ui/storybook/stories/routine-detail-c.stories.tsx +++ b/ui/storybook/stories/routine-detail-c.stories.tsx @@ -1,7 +1,10 @@ -import { useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useQueryClient } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; import type { + DocumentAnnotationThreadWithComments, + RoutineDescriptionDocument, RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariable, @@ -29,6 +32,7 @@ import { RunsSection, ActivitySection, } from "@/components/routine-sections/operate-sections"; +import { queryKeys } from "@/lib/queryKeys"; import { storybookAgents, storybookProjects } from "../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; @@ -103,6 +107,82 @@ const routine: RoutineDetailType = { activeIssue: null, }; +const routineDescriptionDocument: RoutineDescriptionDocument = { + id: "routine-description-doc", + companyId: COMPANY_ID, + routineId: ROUTINE_ID, + key: "description", + title: "Description", + format: "markdown", + body: routine.description ?? "", + latestRevisionId: "routine-description-rev-17", + latestRevisionNumber: 17, + createdByAgentId: null, + createdByUserId: "user-board", + updatedByAgentId: null, + updatedByUserId: "user-board", + createdAt: now, + updatedAt: now, +}; + +const routineAnnotationThreads: DocumentAnnotationThreadWithComments[] = [ + { + id: "routine-thread-1", + companyId: COMPANY_ID, + issueId: null, + routineId: ROUTINE_ID, + documentId: routineDescriptionDocument.id, + documentKey: "description", + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: routineDescriptionDocument.latestRevisionId, + originalRevisionNumber: routineDescriptionDocument.latestRevisionNumber, + currentRevisionId: routineDescriptionDocument.latestRevisionId, + currentRevisionNumber: routineDescriptionDocument.latestRevisionNumber, + selectedText: "Keep it to five bullets", + prefixText: "by {{deadline}}.\n\n", + suffixText: ".", + normalizedStart: 84, + normalizedEnd: 108, + markdownStart: 84, + markdownEnd: 108, + anchorSelector: { + quote: { + exact: "Keep it to five bullets", + prefix: "by {{deadline}}.\n\n", + suffix: ".", + }, + position: { normalizedStart: 84, normalizedEnd: 108, markdownStart: 84, markdownEnd: 108 }, + }, + createdByAgentId: null, + createdByUserId: "user-board", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + createdAt: now, + updatedAt: now, + comments: [ + { + id: "routine-comment-1", + companyId: COMPANY_ID, + threadId: "routine-thread-1", + issueId: null, + routineId: ROUTINE_ID, + documentId: routineDescriptionDocument.id, + body: "The digest constraint is visible here; the panel stays aligned with the routine overview editor.", + authorType: "user", + authorAgentId: null, + authorUserId: "user-board", + createdByRunId: null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }, + ], + }, +]; + const routineRuns = [ { id: "run-1", source: "manual", status: "succeeded", triggeredAt: new Date("2026-06-09T11:48:00Z"), failureReason: null, triggerPayload: { customer_name: "Acme", deadline: "Fri" }, trigger: { label: "manual", kind: "manual" }, linkedIssue: { id: "issue-1", identifier: "PAP-99221", title: "Weekly digest for Acme" } }, { id: "run-2", source: "schedule", status: "failed", triggeredAt: new Date("2026-06-08T14:00:00Z"), failureReason: "Cron timed out after 600s", triggerPayload: { customer_name: "Acme" }, trigger: { label: "schedule", kind: "schedule" }, linkedIssue: { id: "issue-2", identifier: "PAP-99220", title: "Weekly digest for Acme" } }, @@ -156,17 +236,21 @@ function stubMutation(overrides?: Record) { } as never; } -function makeContext(dirty: boolean, navigate: (s: RoutineSectionKey) => void): RoutineDetailContextValue { +function makeContext( + dirty: boolean, + navigate: (s: RoutineSectionKey) => void, + routineDetail: RoutineDetailType = routine, +): RoutineDetailContextValue { const defaults: RoutineEditDraft = { - title: routine.title, - description: routine.description ?? "", - projectId: routine.projectId ?? "", - assigneeAgentId: routine.assigneeAgentId ?? "", - priority: routine.priority, - concurrencyPolicy: routine.concurrencyPolicy, - catchUpPolicy: routine.catchUpPolicy, - variables: routine.variables, - env: routine.env ?? null, + title: routineDetail.title, + description: routineDetail.description ?? "", + projectId: routineDetail.projectId ?? "", + assigneeAgentId: routineDetail.assigneeAgentId ?? "", + priority: routineDetail.priority, + concurrencyPolicy: routineDetail.concurrencyPolicy, + catchUpPolicy: routineDetail.catchUpPolicy, + variables: routineDetail.variables, + env: routineDetail.env ?? null, }; const editDraft: RoutineEditDraft = dirty ? { ...defaults, description: `${defaults.description}\n\nAlways CC the account owner.` } @@ -174,7 +258,7 @@ function makeContext(dirty: boolean, navigate: (s: RoutineSectionKey) => void): const dirtyFields = dirty ? [{ key: "description", label: "the description" }] : []; return { - routine, + routine: routineDetail, routineId: ROUTINE_ID, companyId: COMPANY_ID, editDraft, @@ -242,10 +326,16 @@ const SECTION_TITLES: Record = { history: "History", }; -function SectionBody({ section }: { section: RoutineSectionKey }) { +function SectionBody({ + section, + descriptionAnnotationsInitiallyOpen = false, +}: { + section: RoutineSectionKey; + descriptionAnnotationsInitiallyOpen?: boolean; +}) { switch (section) { case "overview": - return ; + return ; case "triggers": return ; case "variables": @@ -263,9 +353,31 @@ function SectionBody({ section }: { section: RoutineSectionKey }) { } } -function RoutineCShell({ initialSection = "overview", dirty = false }: { initialSection?: RoutineSectionKey; dirty?: boolean }) { +function RoutineCShell({ + initialSection = "overview", + dirty = false, + withDescriptionAnnotations = false, +}: { + initialSection?: RoutineSectionKey; + dirty?: boolean; + withDescriptionAnnotations?: boolean; +}) { const [section, setSection] = useState(initialSection); - const ctx = useMemo(() => makeContext(dirty, setSection), [dirty]); + const queryClient = useQueryClient(); + const routineDetail = useMemo( + () => withDescriptionAnnotations + ? { ...routine, descriptionDocument: routineDescriptionDocument } + : routine, + [withDescriptionAnnotations], + ); + useEffect(() => { + if (!withDescriptionAnnotations) return; + queryClient.setQueryData( + queryKeys.routines.documentAnnotations(ROUTINE_ID, "description", "all"), + routineAnnotationThreads, + ); + }, [queryClient, withDescriptionAnnotations]); + const ctx = useMemo(() => makeContext(dirty, setSection, routineDetail), [dirty, routineDetail]); const isEditable = EDITABLE_SECTIONS.includes(section); return ( @@ -300,7 +412,10 @@ function RoutineCShell({ initialSection = "overview", dirty = false }: { initial

{SECTION_TITLES[section]}

- + {isEditable ? ( ; export const Overview: Story = { args: { initialSection: "overview", dirty: true } }; +export const OverviewDescriptionAnnotations: Story = { + args: { initialSection: "overview", withDescriptionAnnotations: true }, +}; export const Triggers: Story = { args: { initialSection: "triggers" } }; export const Variables: Story = { args: { initialSection: "variables" } }; export const Secrets: Story = { args: { initialSection: "secrets" } };