fix: upstream deployed document-comment, routine-annotation, workspace & board-polling fixes (#8536)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - This change touches several already-shipped subsystems — document-comment annotations, the selected-agent Conference Room chat surface, routine description annotations, workspace-operation tracking, and the board polling/inbox UI > - A batch of incremental fixes and two small backend additions had accumulated on a local mainline and were deployed to a live instance, but never landed upstream — so each `origin/master` sync kept re-diverging > - Leaving them un-upstreamed means the same delta has to be re-merged on every sync and risks being lost or silently reverted > - This pull request rebases that delta cleanly on top of current `origin/master` (preserving recent upstream work such as reusable sandbox leases and the relation-list collapse controls) and brings it up for review > - The benefit is that mainline and the deployed instance converge, and these fixes/additions get normal review + CI + Greptile coverage ## Linked Issues or Issue Description No single GitHub issue tracks this; it is a bundle of bug fixes and two small feature additions. Following the issue-template fields: **Bug fixes (what was wrong → what this does):** - Document comments rendered out of document order, didn't live-update across clients, swallowed save failures, and lost the markdown text selection on re-render. Now: doc-order sort, live updates, surfaced save-failure state, stable selection across re-renders. - The board polling hot path returned oversized payloads on every poll. Now: an opt-in `summary` projection trims the heartbeat-run list payload. - Assorted UI fixes: sidebar nav peek/streamlining edge cases, markdown file-viewer re-mount/line-height issues on iOS Safari, inbox badge/skill deep-link tab selection, and `⌘.` work-mode cycling on iOS. **Feature additions:** - `workspace_operations.issue_id` — associate a workspace operation with the issue that triggered it (new migration `0106`, schema, service, shared type). - Routine **description annotations** — comment threads on a routine's description document, mirroring issue document annotations (new migration `0107`, `routine_documents` schema, routes/service, editable-sections UI). - Selected-agent **Conference Room chat** surface wiring and live issue-thread updates. **Related PR:** #8229 (`feat(control-plane): add annotation and workspace controls`, draft) covers overlapping annotation/workspace-control territory — flagging it so a reviewer can reconcile the two rather than double-merging. ## What Changed - `feat(workspace)`: `workspace_operations.issue_id` migration + schema/service/type; issue workspace property controls reconciled with upstream's evolved "Service" row. - `feat(routines)`: routine description annotations — `routine_documents` schema, migration `0107`, routes/service, `editable-sections` UI. - `fix(document-comments)`: doc-order sort, live updates, save-failure surfacing, stable markdown selection (+ storybook story, rerender test). - `feat(chat)`: selected-agent Conference Room chat surface and live issue-thread updates (`LiveUpdatesProvider`, `issue-chat-messages`, interactions service). - `perf(board)`: opt-in `summary` projection for the board polling/heartbeat-run list payload, plus assorted sidebar / file-viewer / inbox / IssueProperties UI fixes. Organized into 5 logical commits. Migrations are numbered incrementally after upstream's latest (`0105`) — `0106` then `0107`, no journal collision. ## Verification - Built by 3-way merging the deployed delta onto current `origin/master`; the only merge conflict (`IssueProperties.test.tsx`, two adjacent test blocks) was resolved in favor of upstream's evolved "green service link above the workspace row" layout, which matches the merged component's rendered output. - Confirmed recent upstream work is preserved post-merge: reusable sandbox lease teardown (`#8513`), the IssueProperties relation-list collapse controls, and the sidebar streamlined-nav default. - Confirmed the net diff vs `origin/master` is exactly the intended feature delta (65 files) and that overlapping server files (`issues.ts`, `agents.ts`, `heartbeat.ts`) only add feature code without disturbing upstream logic. - This delta is already running on a live deployed instance. - Full typecheck/test suite + Greptile to run in CI (see checklist). ## Risks - Two new migrations (`0106`, `0107`). Both are additive (new table / new nullable column) and ordered after upstream's `0105`; no data backfill, low risk. If another migration-bearing PR merges first, renumber before merge. - Largest blast radius is in the merged overlapping UI/service files; covered by the existing test suites for those files plus CI. - Overlaps thematically with draft PR #8229 — reviewers should reconcile rather than merge both blindly. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use (git, shell). Agentic coding workflow. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2dbaf4a7fa
commit
e68188c438
|
|
@ -141,10 +141,14 @@ Rules:
|
|||
|
||||
## Step 5 — Write the File
|
||||
|
||||
The opening line of the changelog must be an H1 of the format `# Paperclip {version}`
|
||||
(no braces), e.g. `# Paperclip v2026.618.0`. Always include the `Paperclip ` prefix and
|
||||
the `v` on the version.
|
||||
|
||||
Template:
|
||||
|
||||
```markdown
|
||||
# vYYYY.MDD.P
|
||||
# Paperclip vYYYY.MDD.P
|
||||
|
||||
> Released: YYYY-MM-DD
|
||||
|
||||
|
|
@ -188,7 +192,7 @@ If there are no contributors left after exclusions, then just skip this section
|
|||
|
||||
Before handing it off:
|
||||
|
||||
1. confirm the heading is the stable version only
|
||||
1. confirm the H1 heading is `# Paperclip {version}` (e.g. `# Paperclip v2026.618.0`) with the stable version only
|
||||
2. confirm there is no `-canary` language in the title or filename
|
||||
3. confirm any breaking changes have an upgrade path
|
||||
4. present the draft for human sign-off
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
ALTER TABLE "workspace_operations" ADD COLUMN IF NOT EXISTS "issue_id" uuid;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_operations_issue_id_issues_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
WITH "run_issue_candidates" AS (
|
||||
SELECT
|
||||
"heartbeat_runs"."id" AS "run_id",
|
||||
"heartbeat_runs"."company_id" AS "company_id",
|
||||
CASE
|
||||
WHEN "heartbeat_runs"."context_snapshot" ? 'issueId'
|
||||
AND "heartbeat_runs"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
|
||||
THEN ("heartbeat_runs"."context_snapshot"->>'issueId')::uuid
|
||||
ELSE NULL
|
||||
END AS "issue_id"
|
||||
FROM "heartbeat_runs"
|
||||
),
|
||||
"run_issue_attribution" AS (
|
||||
SELECT
|
||||
"run_issue_candidates"."run_id",
|
||||
"issues"."id" AS "issue_id"
|
||||
FROM "run_issue_candidates"
|
||||
INNER JOIN "issues"
|
||||
ON "issues"."id" = "run_issue_candidates"."issue_id"
|
||||
AND "issues"."company_id" = "run_issue_candidates"."company_id"
|
||||
WHERE "run_issue_candidates"."issue_id" IS NOT NULL
|
||||
)
|
||||
UPDATE "workspace_operations"
|
||||
SET
|
||||
"issue_id" = "run_issue_attribution"."issue_id",
|
||||
"updated_at" = now()
|
||||
FROM "run_issue_attribution"
|
||||
WHERE "workspace_operations"."heartbeat_run_id" = "run_issue_attribution"."run_id"
|
||||
AND "workspace_operations"."issue_id" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "workspace_operations_company_workspace_issue_started_idx" ON "workspace_operations" USING btree ("company_id","execution_workspace_id","issue_id","started_at");
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
CREATE TABLE IF NOT EXISTS "routine_documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"routine_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_routine_id_routines_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_document_id_documents_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "routine_documents_company_routine_key_uq" ON "routine_documents" USING btree ("company_id","routine_id","key");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "routine_documents_document_uq" ON "routine_documents" USING btree ("document_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "routine_documents_company_routine_updated_idx" ON "routine_documents" USING btree ("company_id","routine_id","updated_at");
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_annotation_threads" ADD COLUMN IF NOT EXISTS "routine_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_annotation_comments" ADD COLUMN IF NOT EXISTS "routine_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_annotation_threads" ALTER COLUMN "issue_id" DROP NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_annotation_comments" ALTER COLUMN "issue_id" DROP NOT NULL;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_threads_routine_id_routines_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_comments_routine_id_routines_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
|
||||
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");
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
DECLARE
|
||||
routine_row RECORD;
|
||||
created_document_id uuid;
|
||||
created_revision_id uuid;
|
||||
BEGIN
|
||||
FOR routine_row IN
|
||||
SELECT *
|
||||
FROM "routines"
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "routine_documents"
|
||||
WHERE "routine_documents"."routine_id" = "routines"."id"
|
||||
AND "routine_documents"."key" = 'description'
|
||||
)
|
||||
LOOP
|
||||
INSERT INTO "documents" (
|
||||
"company_id",
|
||||
"title",
|
||||
"format",
|
||||
"latest_body",
|
||||
"latest_revision_number",
|
||||
"created_by_agent_id",
|
||||
"created_by_user_id",
|
||||
"updated_by_agent_id",
|
||||
"updated_by_user_id",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
VALUES (
|
||||
routine_row."company_id",
|
||||
'routine description',
|
||||
'markdown',
|
||||
coalesce(routine_row."description", ''),
|
||||
1,
|
||||
routine_row."created_by_agent_id",
|
||||
routine_row."created_by_user_id",
|
||||
routine_row."updated_by_agent_id",
|
||||
routine_row."updated_by_user_id",
|
||||
coalesce(routine_row."created_at", now()),
|
||||
coalesce(routine_row."updated_at", now())
|
||||
)
|
||||
RETURNING "id" INTO created_document_id;
|
||||
|
||||
INSERT INTO "document_revisions" (
|
||||
"company_id",
|
||||
"document_id",
|
||||
"revision_number",
|
||||
"title",
|
||||
"format",
|
||||
"body",
|
||||
"change_summary",
|
||||
"created_by_agent_id",
|
||||
"created_by_user_id",
|
||||
"created_at"
|
||||
)
|
||||
VALUES (
|
||||
routine_row."company_id",
|
||||
created_document_id,
|
||||
1,
|
||||
'routine description',
|
||||
'markdown',
|
||||
coalesce(routine_row."description", ''),
|
||||
'Backfilled routine description',
|
||||
routine_row."created_by_agent_id",
|
||||
routine_row."created_by_user_id",
|
||||
coalesce(routine_row."created_at", now())
|
||||
)
|
||||
RETURNING "id" INTO created_revision_id;
|
||||
|
||||
UPDATE "documents"
|
||||
SET "latest_revision_id" = created_revision_id
|
||||
WHERE "id" = created_document_id;
|
||||
|
||||
INSERT INTO "routine_documents" ("company_id", "routine_id", "document_id", "key", "created_at", "updated_at")
|
||||
VALUES (
|
||||
routine_row."company_id",
|
||||
routine_row."id",
|
||||
created_document_id,
|
||||
'description',
|
||||
coalesce(routine_row."created_at", now()),
|
||||
coalesce(routine_row."updated_at", now())
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_revisions_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "document_revisions" DROP CONSTRAINT "document_revisions_company_id_companies_id_fk";
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_revisions_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'documents_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "documents" DROP CONSTRAINT "documents_company_id_companies_id_fk";
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'documents_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
@ -757,6 +757,27 @@
|
|||
"when": 1782165200000,
|
||||
"tag": "0107_external_object_display_metadata",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 108,
|
||||
"version": "7",
|
||||
"when": 1781902200000,
|
||||
"tag": "0108_workspace_operations_issue_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 109,
|
||||
"version": "7",
|
||||
"when": 1781902300000,
|
||||
"tag": "0109_routine_description_annotations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 110,
|
||||
"version": "7",
|
||||
"when": 1781902400000,
|
||||
"tag": "0110_document_company_cascade",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -7,6 +8,7 @@ import { documents } from "./documents.js";
|
|||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issueComments } from "./issue_comments.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { routines } from "./routines.js";
|
||||
|
||||
export const documentAnnotationComments = pgTable(
|
||||
"document_annotation_comments",
|
||||
|
|
@ -14,7 +16,8 @@ export const documentAnnotationComments = pgTable(
|
|||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }),
|
||||
routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
body: text("body").notNull(),
|
||||
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
|
||||
|
|
@ -36,6 +39,11 @@ export const documentAnnotationComments = pgTable(
|
|||
table.issueId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyRoutineCreatedAtIdx: index("document_annotation_comments_company_routine_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.routineId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
|
|
@ -43,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`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,19 +4,22 @@ 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";
|
||||
import { documents } from "./documents.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { routines } from "./routines.js";
|
||||
|
||||
export const documentAnnotationThreads = pgTable(
|
||||
"document_annotation_threads",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }),
|
||||
routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
documentKey: text("document_key").notNull(),
|
||||
status: text("status").$type<DocumentAnnotationThreadStatus>().notNull().default("open"),
|
||||
|
|
@ -56,6 +59,11 @@ export const documentAnnotationThreads = pgTable(
|
|||
table.issueId,
|
||||
table.status,
|
||||
),
|
||||
companyRoutineStatusIdx: index("document_annotation_threads_company_routine_status_idx").on(
|
||||
table.companyId,
|
||||
table.routineId,
|
||||
table.status,
|
||||
),
|
||||
companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
|
|
@ -66,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`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const documentRevisions = pgTable(
|
|||
"document_revisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
revisionNumber: integer("revision_number").notNull(),
|
||||
title: text("title"),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const documents = pgTable(
|
|||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
format: text("format").notNull().default("markdown"),
|
||||
latestBody: text("latest_body").notNull(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export { issueAttachments } from "./issue_attachments.js";
|
|||
export { documents } from "./documents.js";
|
||||
export { documentRevisions } from "./document_revisions.js";
|
||||
export { issueDocuments } from "./issue_documents.js";
|
||||
export { routineDocuments } from "./routine_documents.js";
|
||||
export { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||
export { documentAnnotationComments } from "./document_annotation_comments.js";
|
||||
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { documents } from "./documents.js";
|
||||
import { routines } from "./routines.js";
|
||||
|
||||
export const routineDocuments = pgTable(
|
||||
"routine_documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
routineId: uuid("routine_id").notNull().references(() => routines.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
key: text("key").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyRoutineKeyUq: uniqueIndex("routine_documents_company_routine_key_uq").on(
|
||||
table.companyId,
|
||||
table.routineId,
|
||||
table.key,
|
||||
),
|
||||
documentUq: uniqueIndex("routine_documents_document_uq").on(table.documentId),
|
||||
companyRoutineUpdatedIdx: index("routine_documents_company_routine_updated_idx").on(
|
||||
table.companyId,
|
||||
table.routineId,
|
||||
table.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { companies } from "./companies.js";
|
||||
import { executionWorkspaces } from "./execution_workspaces.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const workspaceOperations = pgTable(
|
||||
"workspace_operations",
|
||||
|
|
@ -24,6 +25,9 @@ export const workspaceOperations = pgTable(
|
|||
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
issueId: uuid("issue_id").references(() => issues.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
phase: text("phase").notNull(),
|
||||
command: text("command"),
|
||||
cwd: text("cwd"),
|
||||
|
|
@ -53,5 +57,11 @@ export const workspaceOperations = pgTable(
|
|||
table.executionWorkspaceId,
|
||||
table.startedAt,
|
||||
),
|
||||
companyWorkspaceIssueStartedIdx: index("workspace_operations_company_workspace_issue_started_idx").on(
|
||||
table.companyId,
|
||||
table.executionWorkspaceId,
|
||||
table.issueId,
|
||||
table.startedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -760,6 +760,7 @@ export type {
|
|||
Routine,
|
||||
RoutineEnvConfig,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineDescriptionDocument,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineRevisionSnapshotRoutineV1,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ export interface DocumentAnnotationAnchorSnapshot {
|
|||
export interface DocumentAnnotationThread {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
issueId: string | null;
|
||||
routineId?: string | null;
|
||||
documentId: string;
|
||||
documentKey: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
|
|
@ -86,7 +87,8 @@ export interface DocumentAnnotationComment {
|
|||
id: string;
|
||||
companyId: string;
|
||||
threadId: string;
|
||||
issueId: string;
|
||||
issueId: string | null;
|
||||
routineId?: string | null;
|
||||
documentId: string;
|
||||
body: string;
|
||||
authorType: IssueCommentAuthorType;
|
||||
|
|
|
|||
|
|
@ -429,6 +429,7 @@ export type {
|
|||
Routine,
|
||||
RoutineEnvConfig,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineDescriptionDocument,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineRevisionSnapshotRoutineV1,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,24 @@ import type {
|
|||
} from "../constants.js";
|
||||
import type { EnvBinding } from "./secrets.js";
|
||||
|
||||
export interface RoutineDescriptionDocument {
|
||||
id: string;
|
||||
companyId: string;
|
||||
routineId: string;
|
||||
key: "description";
|
||||
title: string | null;
|
||||
format: "markdown";
|
||||
body: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RoutineProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -195,6 +213,7 @@ export interface RoutineDetail extends Routine {
|
|||
project: RoutineProjectSummary | null;
|
||||
assignee: RoutineAgentSummary | null;
|
||||
parentIssue: RoutineIssueSummary | null;
|
||||
descriptionDocument?: RoutineDescriptionDocument | null;
|
||||
triggers: RoutineTrigger[];
|
||||
recentRuns: RoutineRunSummary[];
|
||||
activeIssue: RoutineIssueSummary | null;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface WorkspaceOperation {
|
|||
companyId: string;
|
||||
executionWorkspaceId: string | null;
|
||||
heartbeatRunId: string | null;
|
||||
issueId: string | null;
|
||||
phase: WorkspaceOperationPhase;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
|
|
|
|||
|
|
@ -298,6 +298,10 @@ describe("document annotation routes", () => {
|
|||
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "issue.document_annotation_thread_created",
|
||||
details: expect.objectContaining({
|
||||
key: "plan",
|
||||
documentKey: "plan",
|
||||
}),
|
||||
}));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -324,6 +328,10 @@ describe("document annotation routes", () => {
|
|||
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "issue.document_annotation_comment_added",
|
||||
details: expect.objectContaining({
|
||||
key: "plan",
|
||||
documentKey: "plan",
|
||||
}),
|
||||
}));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
|
||||
|
|
@ -334,6 +342,10 @@ describe("document annotation routes", () => {
|
|||
expect(resolved.body.status).toBe("resolved");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "issue.document_annotation_thread_resolved",
|
||||
details: expect.objectContaining({
|
||||
key: "plan",
|
||||
documentKey: "plan",
|
||||
}),
|
||||
}));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -145,6 +145,85 @@ describeEmbeddedPostgres("heartbeat list", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("returns summary list rows without heavy run detail fields", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "running",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "failed",
|
||||
error: "Failed after doing useful work",
|
||||
usageJson: {
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
inputTokens: 123,
|
||||
},
|
||||
resultJson: {
|
||||
summary: "large run summary",
|
||||
stdout: "x".repeat(20_000),
|
||||
},
|
||||
sessionIdBefore: "session-before",
|
||||
sessionIdAfter: "session-after",
|
||||
logStore: "local",
|
||||
logRef: "logs/run.log",
|
||||
logSha256: "abc123",
|
||||
externalRunId: "external-run",
|
||||
processPid: 12345,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
});
|
||||
|
||||
const runs = await heartbeatService(db).list(companyId, undefined, 5, { summary: true });
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]).toMatchObject({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "failed",
|
||||
error: "Failed after doing useful work",
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logSha256: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds oversized legacy result json payloads on getRun", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -1345,13 +1345,23 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
});
|
||||
|
||||
describe("workspace_finalize accept gate", () => {
|
||||
async function seedAcceptGateFixture() {
|
||||
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
|
||||
|
||||
async function seedAcceptGateFixture(options?: {
|
||||
kind?: AcceptGateInteractionKind;
|
||||
sourceRunId?: string | null;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const sourceRunId =
|
||||
options?.sourceRunId === null ? null : options?.sourceRunId ?? randomUUID();
|
||||
const foreignRunId = randomUUID();
|
||||
const kind = options?.kind ?? "request_confirmation";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
|
|
@ -1375,6 +1385,40 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
...(sourceRunId
|
||||
? [
|
||||
{
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T21:55:00.000Z"),
|
||||
finishedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: foreignRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
},
|
||||
]);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
|
|
@ -1404,107 +1448,63 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const payload = kind === "request_checkbox_confirmation"
|
||||
? {
|
||||
version: 1 as const,
|
||||
prompt: "Which files should be accepted?",
|
||||
options: [
|
||||
{ id: "file-a", label: "a.txt" },
|
||||
{ id: "file-b", label: "b.txt" },
|
||||
],
|
||||
minSelected: 0,
|
||||
maxSelected: 2,
|
||||
}
|
||||
: {
|
||||
version: 1 as const,
|
||||
prompt: "Mark this issue done?",
|
||||
};
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
kind,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
sourceRunId,
|
||||
payload,
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
|
||||
return {
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId,
|
||||
issueId,
|
||||
goalId,
|
||||
interactionId: created.id,
|
||||
sourceRunId,
|
||||
foreignRunId,
|
||||
};
|
||||
}
|
||||
|
||||
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
// A run touched the workspace (prepare) but never recorded workspace_finalize.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
||||
await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
|
@ -1518,6 +1518,208 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses request_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
||||
await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining(
|
||||
"the run that created this interaction has not finished syncing its workspace",
|
||||
),
|
||||
details: { executionWorkspaceId, sourceRunId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_confirmation accept when sourceRunId is null", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ sourceRunId: null });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_checkbox_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-b"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
result: {
|
||||
selectedOptionIds: ["file-b"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses request_checkbox_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining(
|
||||
"the run that created this interaction has not finished syncing its workspace",
|
||||
),
|
||||
details: { executionWorkspaceId, sourceRunId },
|
||||
});
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_checkbox_confirmation accept when sourceRunId is null", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation", sourceRunId: null });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
|
@ -1528,11 +1730,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
// workspace_finalize gate (PAPA-440) must not apply here. Without this
|
||||
// carve-out the board cannot triage suggested tasks on an issue whose
|
||||
// latest workspace op is still worktree_prepare.
|
||||
const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture();
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, foreignRunId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-28T22:00:00.000Z"),
|
||||
|
|
|
|||
|
|
@ -2924,6 +2924,102 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedSharedWorkspaceDependency() {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
const foreignIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
id: foreignIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Foreign in-flight issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
|
||||
return {
|
||||
companyId,
|
||||
assigneeAgentId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
foreignIssueId,
|
||||
};
|
||||
}
|
||||
|
||||
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
|
@ -3081,86 +3177,82 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
]);
|
||||
});
|
||||
|
||||
it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
foreignIssueId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
executionWorkspaceId,
|
||||
issueId: foreignIssueId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
// A run touched the workspace (prepare phase) but has not yet recorded
|
||||
it("ignores unattributed pre-backfill workspace operations when checking blocker readiness", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: null,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
assigneeAgentId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
// The blocker touched its workspace but has not yet recorded
|
||||
// workspace_finalize — the dependent must NOT wake.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
|
|
@ -3177,6 +3269,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
|
|
@ -3188,6 +3281,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const routineId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const otherCompanyId = "33333333-3333-4333-8333-333333333333";
|
||||
const agentId = "77777777-7777-4777-8777-777777777777";
|
||||
const firstRevisionId = "44444444-4444-4444-8444-444444444444";
|
||||
const secondRevisionId = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
const mockRoutineService = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
getDetail: vi.fn(),
|
||||
getDescriptionDocument: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockAnnotationService = vi.hoisted(() => ({
|
||||
listThreadsForRoutineDocument: vi.fn(),
|
||||
getThreadForRoutineDocument: vi.fn(),
|
||||
createRoutineThread: vi.fn(),
|
||||
addRoutineComment: vi.fn(),
|
||||
updateRoutineThread: vi.fn(),
|
||||
remapOpenThreadsForRoutineDocument: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
const routine = {
|
||||
id: routineId,
|
||||
companyId,
|
||||
title: "Daily summary",
|
||||
description: "Alpha selected text omega",
|
||||
status: "active",
|
||||
assigneeAgentId: agentId,
|
||||
latestRevisionId: firstRevisionId,
|
||||
latestRevisionNumber: 1,
|
||||
};
|
||||
|
||||
const descriptionDocument = {
|
||||
id: "document-1",
|
||||
companyId,
|
||||
routineId,
|
||||
key: "description",
|
||||
title: "Routine instructions",
|
||||
format: "markdown",
|
||||
body: "Alpha selected text omega",
|
||||
latestRevisionId: firstRevisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "board-user",
|
||||
createdAt: new Date("2026-06-16T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-16T12:00:00.000Z"),
|
||||
};
|
||||
|
||||
const updatedDescriptionDocument = {
|
||||
...descriptionDocument,
|
||||
body: "Alpha updated selected text omega",
|
||||
latestRevisionId: secondRevisionId,
|
||||
latestRevisionNumber: 2,
|
||||
updatedAt: new Date("2026-06-16T12:02:00.000Z"),
|
||||
};
|
||||
|
||||
const selector = {
|
||||
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
|
||||
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
|
||||
};
|
||||
|
||||
const annotationThread = {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId,
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: descriptionDocument.id,
|
||||
documentKey: "description",
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: firstRevisionId,
|
||||
originalRevisionNumber: 1,
|
||||
currentRevisionId: firstRevisionId,
|
||||
currentRevisionNumber: 1,
|
||||
selectedText: "selected text",
|
||||
prefixText: "Alpha ",
|
||||
suffixText: " omega",
|
||||
normalizedStart: 6,
|
||||
normalizedEnd: 19,
|
||||
markdownStart: 6,
|
||||
markdownEnd: 19,
|
||||
anchorSelector: selector,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-06-16T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-06-16T12:01:00.000Z"),
|
||||
};
|
||||
|
||||
const annotationComment = {
|
||||
id: "66666666-6666-4666-8666-666666666666",
|
||||
companyId,
|
||||
threadId: annotationThread.id,
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: descriptionDocument.id,
|
||||
body: "Please tighten this",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-user",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-06-16T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-06-16T12:01:00.000Z"),
|
||||
};
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
}),
|
||||
documentAnnotationService: () => mockAnnotationService,
|
||||
logActivity: mockLogActivity,
|
||||
routineService: () => mockRoutineService,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(actor: "board" | "agent" = "board", actorCompanyId = companyId) {
|
||||
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor === "agent"
|
||||
? {
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId: actorCompanyId,
|
||||
runId: "88888888-8888-4888-8888-888888888888",
|
||||
}
|
||||
: {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [actorCompanyId],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", routineRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("routine description annotation routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/routines.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockRoutineService.get.mockResolvedValue(routine);
|
||||
mockRoutineService.getDescriptionDocument.mockResolvedValue(updatedDescriptionDocument);
|
||||
mockRoutineService.update.mockResolvedValue({
|
||||
...routine,
|
||||
description: "Alpha updated selected text omega",
|
||||
latestRevisionId: secondRevisionId,
|
||||
latestRevisionNumber: 2,
|
||||
});
|
||||
mockAnnotationService.listThreadsForRoutineDocument.mockImplementation(async (
|
||||
_routineId: string,
|
||||
_key: string,
|
||||
options?: { includeComments?: boolean },
|
||||
) => (
|
||||
options?.includeComments
|
||||
? [{ ...annotationThread, comments: [annotationComment] }]
|
||||
: [annotationThread]
|
||||
));
|
||||
mockAnnotationService.getThreadForRoutineDocument.mockResolvedValue({
|
||||
...annotationThread,
|
||||
comments: [annotationComment],
|
||||
});
|
||||
mockAnnotationService.createRoutineThread.mockResolvedValue({
|
||||
...annotationThread,
|
||||
comments: [annotationComment],
|
||||
});
|
||||
mockAnnotationService.addRoutineComment.mockResolvedValue(annotationComment);
|
||||
mockAnnotationService.updateRoutineThread.mockResolvedValue({ ...annotationThread, status: "resolved" });
|
||||
mockAnnotationService.remapOpenThreadsForRoutineDocument.mockResolvedValue([
|
||||
{
|
||||
thread: {
|
||||
...annotationThread,
|
||||
currentRevisionId: secondRevisionId,
|
||||
currentRevisionNumber: 2,
|
||||
},
|
||||
snapshot: { id: "snapshot-1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates, replies to, resolves, and reopens routine description annotation threads", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
const created = await request(app)
|
||||
.post(`/api/routines/${routineId}/description/annotations`)
|
||||
.send({
|
||||
baseRevisionId: firstRevisionId,
|
||||
baseRevisionNumber: 1,
|
||||
selector,
|
||||
body: "Please tighten this",
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(created.body.id).toBe(annotationThread.id);
|
||||
expect(mockAnnotationService.createRoutineThread).toHaveBeenCalledWith(
|
||||
routineId,
|
||||
"description",
|
||||
expect.objectContaining({ baseRevisionId: firstRevisionId, body: "Please tighten this" }),
|
||||
expect.objectContaining({ actorType: "user", userId: "board-user" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_thread_created",
|
||||
entityType: "routine",
|
||||
entityId: routineId,
|
||||
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
|
||||
}));
|
||||
|
||||
await request(app)
|
||||
.post(`/api/routines/${routineId}/description/annotations/${annotationThread.id}/comments`)
|
||||
.send({ body: "Reply on the same thread" })
|
||||
.expect(201);
|
||||
expect(mockAnnotationService.addRoutineComment).toHaveBeenCalledWith(
|
||||
routineId,
|
||||
"description",
|
||||
annotationThread.id,
|
||||
expect.objectContaining({ body: "Reply on the same thread" }),
|
||||
expect.objectContaining({ actorType: "user", userId: "board-user" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_comment_added",
|
||||
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
|
||||
}));
|
||||
|
||||
await request(app)
|
||||
.patch(`/api/routines/${routineId}/description/annotations/${annotationThread.id}`)
|
||||
.send({ status: "resolved" })
|
||||
.expect(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_thread_resolved",
|
||||
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
|
||||
}));
|
||||
|
||||
mockAnnotationService.updateRoutineThread.mockResolvedValueOnce({ ...annotationThread, status: "open" });
|
||||
await request(app)
|
||||
.patch(`/api/routines/${routineId}/description/annotations/${annotationThread.id}`)
|
||||
.send({ status: "open" })
|
||||
.expect(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_thread_reopened",
|
||||
details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("remaps open routine description annotations after routine description revisions", async () => {
|
||||
const updated = await request(await createApp())
|
||||
.patch(`/api/routines/${routineId}`)
|
||||
.send({
|
||||
description: "Alpha updated selected text omega",
|
||||
baseRevisionId: firstRevisionId,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(updated.body.latestRevisionNumber).toBe(2);
|
||||
expect(mockRoutineService.getDescriptionDocument).toHaveBeenCalledWith(routineId);
|
||||
expect(mockAnnotationService.remapOpenThreadsForRoutineDocument).toHaveBeenCalledWith({
|
||||
routineId,
|
||||
key: "description",
|
||||
documentId: updatedDescriptionDocument.id,
|
||||
nextRevisionId: updatedDescriptionDocument.latestRevisionId,
|
||||
nextRevisionNumber: updatedDescriptionDocument.latestRevisionNumber,
|
||||
nextBody: updatedDescriptionDocument.body,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_remapped",
|
||||
entityType: "routine",
|
||||
entityId: routineId,
|
||||
details: expect.objectContaining({
|
||||
documentKey: "description",
|
||||
threadId: annotationThread.id,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects agent cross-company routine annotation reads", async () => {
|
||||
await request(await createApp("agent", otherCompanyId))
|
||||
.get(`/api/routines/${routineId}/description/annotations`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
|
@ -101,6 +101,7 @@ const mockRoutineService = vi.hoisted(() => ({
|
|||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getDetail: vi.fn(),
|
||||
getDescriptionDocument: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
listRevisions: vi.fn(),
|
||||
|
|
@ -115,6 +116,15 @@ const mockRoutineService = vi.hoisted(() => ({
|
|||
firePublicTrigger: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAnnotationService = vi.hoisted(() => ({
|
||||
listThreadsForRoutineDocument: vi.fn(),
|
||||
getThreadForRoutineDocument: vi.fn(),
|
||||
createRoutineThread: vi.fn(),
|
||||
addRoutineComment: vi.fn(),
|
||||
updateRoutineThread: vi.fn(),
|
||||
remapOpenThreadsForRoutineDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
|
@ -149,6 +159,7 @@ function registerModuleMocks() {
|
|||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
documentAnnotationService: () => mockAnnotationService,
|
||||
logActivity: mockLogActivity,
|
||||
routineService: () => mockRoutineService,
|
||||
}));
|
||||
|
|
@ -205,6 +216,96 @@ describe("routine routes", () => {
|
|||
});
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockRoutineService.getDescriptionDocument.mockResolvedValue({
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
companyId,
|
||||
routineId,
|
||||
key: "description",
|
||||
title: "Routine description",
|
||||
format: "markdown",
|
||||
body: "Alpha selected text omega",
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
});
|
||||
mockAnnotationService.listThreadsForRoutineDocument.mockResolvedValue([]);
|
||||
mockAnnotationService.getThreadForRoutineDocument.mockResolvedValue(null);
|
||||
const annotationThread = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId,
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: "99999999-9999-4999-8999-999999999999",
|
||||
documentKey: "description",
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: revisionId,
|
||||
originalRevisionNumber: 1,
|
||||
currentRevisionId: revisionId,
|
||||
currentRevisionNumber: 1,
|
||||
selectedText: "selected text",
|
||||
prefixText: "Alpha ",
|
||||
suffixText: " omega",
|
||||
normalizedStart: 6,
|
||||
normalizedEnd: 19,
|
||||
markdownStart: 6,
|
||||
markdownEnd: 19,
|
||||
anchorSelector: {
|
||||
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
|
||||
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
comments: [{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
companyId,
|
||||
threadId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: "99999999-9999-4999-8999-999999999999",
|
||||
body: "Please review",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-user",
|
||||
createdByRunId: null,
|
||||
issueCommentId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
}],
|
||||
};
|
||||
mockAnnotationService.createRoutineThread.mockResolvedValue(annotationThread);
|
||||
mockAnnotationService.addRoutineComment.mockResolvedValue({
|
||||
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
companyId,
|
||||
threadId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: "99999999-9999-4999-8999-999999999999",
|
||||
body: "Reply",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-user",
|
||||
createdByRunId: null,
|
||||
issueCommentId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
});
|
||||
mockAnnotationService.updateRoutineThread.mockResolvedValue({
|
||||
...annotationThread,
|
||||
status: "resolved",
|
||||
});
|
||||
mockAnnotationService.remapOpenThreadsForRoutineDocument.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("passes project filters to the routine list service", async () => {
|
||||
|
|
@ -240,6 +341,86 @@ describe("routine routes", () => {
|
|||
expect(res.body[0]).toMatchObject({ id: revisionId, revisionNumber: 1 });
|
||||
});
|
||||
|
||||
it("creates, replies to, and resolves routine description annotation threads", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const selector = {
|
||||
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
|
||||
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
|
||||
};
|
||||
|
||||
const created = await request(app)
|
||||
.post(`/api/routines/${routineId}/description/annotations`)
|
||||
.send({
|
||||
baseRevisionId: revisionId,
|
||||
baseRevisionNumber: 1,
|
||||
selector,
|
||||
body: "Please review",
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(created.body).toMatchObject({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
routineId,
|
||||
issueId: null,
|
||||
documentKey: "description",
|
||||
});
|
||||
expect(mockAnnotationService.createRoutineThread).toHaveBeenCalledWith(
|
||||
routineId,
|
||||
"description",
|
||||
expect.objectContaining({ body: "Please review" }),
|
||||
expect.objectContaining({ actorType: "user", userId: "board-user" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_thread_created",
|
||||
entityType: "routine",
|
||||
entityId: routineId,
|
||||
details: expect.objectContaining({ documentKey: "description" }),
|
||||
}));
|
||||
|
||||
await request(app)
|
||||
.post(`/api/routines/${routineId}/description/annotations/${created.body.id}/comments`)
|
||||
.send({ body: "Reply" })
|
||||
.expect(201);
|
||||
expect(mockAnnotationService.addRoutineComment).toHaveBeenCalledWith(
|
||||
routineId,
|
||||
"description",
|
||||
created.body.id,
|
||||
expect.objectContaining({ body: "Reply" }),
|
||||
expect.objectContaining({ actorType: "user", userId: "board-user" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_comment_added",
|
||||
entityType: "routine",
|
||||
entityId: routineId,
|
||||
}));
|
||||
|
||||
const resolved = await request(app)
|
||||
.patch(`/api/routines/${routineId}/description/annotations/${created.body.id}`)
|
||||
.send({ status: "resolved" })
|
||||
.expect(200);
|
||||
|
||||
expect(resolved.body.status).toBe("resolved");
|
||||
expect(mockAnnotationService.updateRoutineThread).toHaveBeenCalledWith(
|
||||
routineId,
|
||||
"description",
|
||||
created.body.id,
|
||||
expect.objectContaining({ status: "resolved" }),
|
||||
expect.objectContaining({ actorType: "user", userId: "board-user" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "routine.document_annotation_thread_resolved",
|
||||
entityType: "routine",
|
||||
entityId: routineId,
|
||||
}));
|
||||
});
|
||||
|
||||
it("blocks routine revision reads across company scope", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ function createWorkspaceOperationRecorderDouble() {
|
|||
companyId: "company-1",
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: "run-1",
|
||||
issueId: null,
|
||||
phase: input.phase,
|
||||
command: input.command ?? null,
|
||||
cwd: input.cwd ?? null,
|
||||
|
|
|
|||
|
|
@ -3439,7 +3439,8 @@ export function agentRoutes(
|
|||
const agentId = req.query.agentId as string | undefined;
|
||||
const limitParam = req.query.limit as string | undefined;
|
||||
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
|
||||
const runs = await heartbeat.list(companyId, agentId, limit);
|
||||
const summary = req.query.summary === "true" || req.query.summary === "1";
|
||||
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
|
||||
res.json(runs);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3906,6 +3906,7 @@ export function issueRoutes(
|
|||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
|
|
@ -3990,6 +3991,7 @@ export function issueRoutes(
|
|||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: keyParsed.data,
|
||||
documentKey: keyParsed.data,
|
||||
threadId: comment.threadId,
|
||||
commentId: comment.id,
|
||||
|
|
@ -4043,6 +4045,7 @@ export function issueRoutes(
|
|||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
|
|
|
|||
|
|
@ -4544,6 +4544,8 @@ registerCurrentRoute({
|
|||
for (const route of [
|
||||
["get", "/api/routines/{id}/revisions", "List routine revisions"],
|
||||
["post", "/api/routines/{id}/revisions/{revisionId}/restore", "Restore a routine revision"],
|
||||
["get", "/api/routines/{id}/description/annotations", "List routine description annotation threads"],
|
||||
["get", "/api/routines/{id}/description/annotations/{threadId}", "Get a routine description annotation thread"],
|
||||
] as const) {
|
||||
registerCurrentRoute({
|
||||
method: route[0],
|
||||
|
|
@ -4553,6 +4555,32 @@ for (const route of [
|
|||
});
|
||||
}
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/routines/{id}/description/annotations",
|
||||
tags: ["routines"],
|
||||
summary: "Create a routine description annotation thread",
|
||||
body: createDocumentAnnotationThreadSchema,
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/routines/{id}/description/annotations/{threadId}/comments",
|
||||
tags: ["routines"],
|
||||
summary: "Add a routine description annotation comment",
|
||||
body: createDocumentAnnotationCommentSchema,
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "patch",
|
||||
path: "/api/routines/{id}/description/annotations/{threadId}",
|
||||
tags: ["routines"],
|
||||
summary: "Update a routine description annotation thread",
|
||||
body: updateDocumentAnnotationThreadSchema,
|
||||
});
|
||||
|
||||
const pluginLocalFolderRequestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
access: z.enum(["read", "readWrite"]).optional(),
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@ import { Router, type Request } from "express";
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createRoutineSchema,
|
||||
createDocumentAnnotationCommentSchema,
|
||||
createDocumentAnnotationThreadSchema,
|
||||
createRoutineTriggerSchema,
|
||||
rotateRoutineTriggerSecretSchema,
|
||||
runRoutineSchema,
|
||||
updateDocumentAnnotationThreadSchema,
|
||||
updateRoutineSchema,
|
||||
updateRoutineTriggerSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackRoutineCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, logActivity, routineService } from "../services/index.js";
|
||||
import { accessService, documentAnnotationService, logActivity, routineService } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
|
@ -24,7 +27,63 @@ export function routineRoutes(
|
|||
const svc = routineService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
const documentAnnotationsSvc = documentAnnotationService(db);
|
||||
const access = accessService(db);
|
||||
const routineDocumentKey = "description";
|
||||
|
||||
function parseBooleanQuery(value: unknown) {
|
||||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function annotationActorInput(req: Request) {
|
||||
const actor = getActorInfo(req);
|
||||
return {
|
||||
actor,
|
||||
annotationActor: {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
runId: actor.runId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function remapRoutineDescriptionAnnotations(req: Request, routineId: string) {
|
||||
const doc = await svc.getDescriptionDocument(routineId);
|
||||
if (!doc) return;
|
||||
const remapped = await documentAnnotationsSvc.remapOpenThreadsForRoutineDocument({
|
||||
routineId,
|
||||
key: routineDocumentKey,
|
||||
documentId: doc.id,
|
||||
nextRevisionId: doc.latestRevisionId,
|
||||
nextRevisionNumber: doc.latestRevisionNumber,
|
||||
nextBody: doc.body,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
for (const remap of remapped) {
|
||||
await logActivity(db, {
|
||||
companyId: doc.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "routine.document_annotation_remapped",
|
||||
entityType: "routine",
|
||||
entityId: routineId,
|
||||
details: {
|
||||
key: doc.key,
|
||||
documentKey: doc.key,
|
||||
documentId: doc.id,
|
||||
threadId: remap.thread.id,
|
||||
revisionNumber: doc.latestRevisionNumber,
|
||||
anchorState: remap.thread.anchorState,
|
||||
anchorConfidence: remap.thread.anchorConfidence,
|
||||
snapshotId: remap.snapshot.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBoardCanAssignTasks(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
@ -149,6 +208,156 @@ export function routineRoutes(
|
|||
res.json(revisions);
|
||||
});
|
||||
|
||||
router.get("/routines/:id/description/annotations", async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open";
|
||||
const threads = await documentAnnotationsSvc.listThreadsForRoutineDocument(routine.id, routineDocumentKey, {
|
||||
status,
|
||||
includeComments: parseBooleanQuery(req.query.includeComments),
|
||||
});
|
||||
res.json(threads);
|
||||
});
|
||||
|
||||
router.get("/routines/:id/description/annotations/:threadId", async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
const thread = await documentAnnotationsSvc.getThreadForRoutineDocument(
|
||||
routine.id,
|
||||
routineDocumentKey,
|
||||
req.params.threadId as string,
|
||||
);
|
||||
if (!thread) {
|
||||
res.status(404).json({ error: "Annotation thread not found" });
|
||||
return;
|
||||
}
|
||||
res.json(thread);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/routines/:id/description/annotations",
|
||||
validate(createDocumentAnnotationThreadSchema),
|
||||
async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
const { actor, annotationActor } = annotationActorInput(req);
|
||||
const thread = await documentAnnotationsSvc.createRoutineThread(
|
||||
routine.id,
|
||||
routineDocumentKey,
|
||||
req.body,
|
||||
annotationActor,
|
||||
);
|
||||
const firstComment = thread.comments[0];
|
||||
await logActivity(db, {
|
||||
companyId: routine.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "routine.document_annotation_thread_created",
|
||||
entityType: "routine",
|
||||
entityId: routine.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
commentId: firstComment?.id ?? null,
|
||||
revisionNumber: thread.currentRevisionNumber,
|
||||
quote: thread.selectedText.slice(0, 240),
|
||||
},
|
||||
});
|
||||
res.status(201).json(thread);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/routines/:id/description/annotations/:threadId/comments",
|
||||
validate(createDocumentAnnotationCommentSchema),
|
||||
async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
const { actor, annotationActor } = annotationActorInput(req);
|
||||
const comment = await documentAnnotationsSvc.addRoutineComment(
|
||||
routine.id,
|
||||
routineDocumentKey,
|
||||
req.params.threadId as string,
|
||||
req.body,
|
||||
annotationActor,
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: routine.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "routine.document_annotation_comment_added",
|
||||
entityType: "routine",
|
||||
entityId: routine.id,
|
||||
details: {
|
||||
key: routineDocumentKey,
|
||||
documentKey: routineDocumentKey,
|
||||
threadId: comment.threadId,
|
||||
commentId: comment.id,
|
||||
bodySnippet: comment.body.slice(0, 120),
|
||||
},
|
||||
});
|
||||
res.status(201).json(comment);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/routines/:id/description/annotations/:threadId",
|
||||
validate(updateDocumentAnnotationThreadSchema),
|
||||
async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
res.status(404).json({ error: "Routine not found" });
|
||||
return;
|
||||
}
|
||||
const { actor, annotationActor } = annotationActorInput(req);
|
||||
const thread = await documentAnnotationsSvc.updateRoutineThread(
|
||||
routine.id,
|
||||
routineDocumentKey,
|
||||
req.params.threadId as string,
|
||||
req.body,
|
||||
annotationActor,
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: routine.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: thread.status === "resolved"
|
||||
? "routine.document_annotation_thread_resolved"
|
||||
: "routine.document_annotation_thread_reopened",
|
||||
entityType: "routine",
|
||||
entityId: routine.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
status: thread.status,
|
||||
},
|
||||
});
|
||||
res.json(thread);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
|
||||
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||
if (!routine) {
|
||||
|
|
@ -193,6 +402,7 @@ export function routineRoutes(
|
|||
details: { title: updated?.title ?? routine.title },
|
||||
});
|
||||
if (updated && updated.latestRevisionId !== routine.latestRevisionId) {
|
||||
await remapRoutineDescriptionAnnotations(req, routine.id);
|
||||
await logRoutineRevisionCreated(req, {
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
|
|
@ -235,6 +445,7 @@ export function routineRoutes(
|
|||
triggerCount: result.revision.snapshot.triggers.length,
|
||||
},
|
||||
});
|
||||
await remapRoutineDescriptionAnnotations(req, routine.id);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
documents,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
routineDocuments,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
anchorSnapshotToSelector,
|
||||
|
|
@ -40,10 +41,21 @@ type IssueDocumentRow = {
|
|||
latestRevisionNumber: number;
|
||||
};
|
||||
|
||||
type RoutineDocumentRow = {
|
||||
routineId: string;
|
||||
companyId: string;
|
||||
documentId: string;
|
||||
documentKey: string;
|
||||
latestBody: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
};
|
||||
|
||||
const threadSelect = {
|
||||
id: documentAnnotationThreads.id,
|
||||
companyId: documentAnnotationThreads.companyId,
|
||||
issueId: documentAnnotationThreads.issueId,
|
||||
routineId: documentAnnotationThreads.routineId,
|
||||
documentId: documentAnnotationThreads.documentId,
|
||||
documentKey: documentAnnotationThreads.documentKey,
|
||||
status: documentAnnotationThreads.status,
|
||||
|
|
@ -75,6 +87,7 @@ const commentSelect = {
|
|||
companyId: documentAnnotationComments.companyId,
|
||||
threadId: documentAnnotationComments.threadId,
|
||||
issueId: documentAnnotationComments.issueId,
|
||||
routineId: documentAnnotationComments.routineId,
|
||||
documentId: documentAnnotationComments.documentId,
|
||||
body: documentAnnotationComments.body,
|
||||
authorType: documentAnnotationComments.authorType,
|
||||
|
|
@ -116,6 +129,27 @@ export function documentAnnotationService(db: Db) {
|
|||
.then((rows: IssueDocumentRow[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getRoutineDocument(
|
||||
routineId: string,
|
||||
key: string,
|
||||
dbOrTx: any = db,
|
||||
): Promise<RoutineDocumentRow | null> {
|
||||
return dbOrTx
|
||||
.select({
|
||||
routineId: routineDocuments.routineId,
|
||||
companyId: documents.companyId,
|
||||
documentId: documents.id,
|
||||
documentKey: routineDocuments.key,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
})
|
||||
.from(routineDocuments)
|
||||
.innerJoin(documents, eq(routineDocuments.documentId, documents.id))
|
||||
.where(and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key)))
|
||||
.then((rows: RoutineDocumentRow[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getThreadForIssue(
|
||||
issueId: string,
|
||||
documentKey: string,
|
||||
|
|
@ -133,6 +167,23 @@ export function documentAnnotationService(db: Db) {
|
|||
.then((rows: DocumentAnnotationThread[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getThreadForRoutine(
|
||||
routineId: string,
|
||||
documentKey: string,
|
||||
threadId: string,
|
||||
dbOrTx: any = db,
|
||||
): Promise<DocumentAnnotationThread | null> {
|
||||
return dbOrTx
|
||||
.select(threadSelect)
|
||||
.from(documentAnnotationThreads)
|
||||
.where(and(
|
||||
eq(documentAnnotationThreads.id, threadId),
|
||||
eq(documentAnnotationThreads.routineId, routineId),
|
||||
eq(documentAnnotationThreads.documentKey, documentKey),
|
||||
))
|
||||
.then((rows: DocumentAnnotationThread[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function commentsForThreads(threadIds: string[], dbOrTx: any = db): Promise<DocumentAnnotationComment[]> {
|
||||
if (threadIds.length === 0) return [];
|
||||
return dbOrTx
|
||||
|
|
@ -197,6 +248,39 @@ export function documentAnnotationService(db: Db) {
|
|||
}));
|
||||
},
|
||||
|
||||
listThreadsForRoutineDocument: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {},
|
||||
) => {
|
||||
const doc = await getRoutineDocument(routineId, key);
|
||||
if (!doc) throw notFound("Document not found");
|
||||
const conditions = [
|
||||
eq(documentAnnotationThreads.routineId, routineId),
|
||||
eq(documentAnnotationThreads.documentId, doc.documentId),
|
||||
];
|
||||
if (options.status && options.status !== "all") {
|
||||
conditions.push(eq(documentAnnotationThreads.status, options.status));
|
||||
}
|
||||
const threads: DocumentAnnotationThread[] = await db
|
||||
.select(threadSelect)
|
||||
.from(documentAnnotationThreads)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id));
|
||||
if (!options.includeComments) return threads;
|
||||
const comments = await commentsForThreads(threads.map((thread) => thread.id));
|
||||
const commentsByThread = new Map<string, DocumentAnnotationComment[]>();
|
||||
for (const comment of comments) {
|
||||
const existing = commentsByThread.get(comment.threadId) ?? [];
|
||||
existing.push(comment);
|
||||
commentsByThread.set(comment.threadId, existing);
|
||||
}
|
||||
return threads.map((thread) => ({
|
||||
...thread,
|
||||
comments: commentsByThread.get(thread.id) ?? [],
|
||||
}));
|
||||
},
|
||||
|
||||
getThreadForIssueDocument: async (issueId: string, key: string, threadId: string) => {
|
||||
const thread = await getThreadForIssue(issueId, key, threadId);
|
||||
if (!thread) return null;
|
||||
|
|
@ -204,6 +288,13 @@ export function documentAnnotationService(db: Db) {
|
|||
return { ...thread, comments };
|
||||
},
|
||||
|
||||
getThreadForRoutineDocument: async (routineId: string, key: string, threadId: string) => {
|
||||
const thread = await getThreadForRoutine(routineId, key, threadId);
|
||||
if (!thread) return null;
|
||||
const comments = await commentsForThreads([thread.id]);
|
||||
return { ...thread, comments };
|
||||
},
|
||||
|
||||
createThread: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
|
|
@ -291,6 +382,94 @@ export function documentAnnotationService(db: Db) {
|
|||
return { ...thread, comments: [comment] };
|
||||
}),
|
||||
|
||||
createRoutineThread: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
input: CreateDocumentAnnotationThread,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${documents.id}
|
||||
from ${routineDocuments}
|
||||
inner join ${documents} on ${routineDocuments.documentId} = ${documents.id}
|
||||
where ${and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key))}
|
||||
for update of ${documents}
|
||||
`);
|
||||
const doc = await getRoutineDocument(routineId, key, tx);
|
||||
if (!doc) throw notFound("Document not found");
|
||||
if (
|
||||
input.baseRevisionId !== doc.latestRevisionId
|
||||
|| input.baseRevisionNumber !== doc.latestRevisionNumber
|
||||
) {
|
||||
throw conflict("Annotation anchor requires the current document revision", {
|
||||
currentRevisionId: doc.latestRevisionId,
|
||||
currentRevisionNumber: doc.latestRevisionNumber,
|
||||
});
|
||||
}
|
||||
|
||||
const verification = verifyDocumentAnchorSelector({
|
||||
markdown: doc.latestBody,
|
||||
selector: input.selector,
|
||||
});
|
||||
if (!verification.ok || !verification.anchor) {
|
||||
throw unprocessable("Annotation anchor does not match the current document revision", {
|
||||
reason: verification.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [thread] = await tx
|
||||
.insert(documentAnnotationThreads)
|
||||
.values({
|
||||
companyId: doc.companyId,
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: doc.documentId,
|
||||
documentKey: doc.documentKey,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: doc.latestRevisionId,
|
||||
originalRevisionNumber: doc.latestRevisionNumber,
|
||||
currentRevisionId: doc.latestRevisionId,
|
||||
currentRevisionNumber: doc.latestRevisionNumber,
|
||||
selectedText: verification.anchor.selectedText,
|
||||
prefixText: verification.anchor.prefixText,
|
||||
suffixText: verification.anchor.suffixText,
|
||||
normalizedStart: verification.anchor.normalizedStart,
|
||||
normalizedEnd: verification.anchor.normalizedEnd,
|
||||
markdownStart: verification.anchor.markdownStart,
|
||||
markdownEnd: verification.anchor.markdownEnd,
|
||||
anchorSelector: input.selector,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(threadSelect);
|
||||
|
||||
const [comment] = await tx
|
||||
.insert(documentAnnotationComments)
|
||||
.values({
|
||||
companyId: doc.companyId,
|
||||
threadId: thread.id,
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: doc.documentId,
|
||||
body: input.body,
|
||||
authorType: actor.actorType,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(commentSelect);
|
||||
|
||||
return { ...thread, comments: [comment] };
|
||||
}),
|
||||
|
||||
addComment: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
|
|
@ -326,6 +505,41 @@ export function documentAnnotationService(db: Db) {
|
|||
return comment;
|
||||
}),
|
||||
|
||||
addRoutineComment: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
input: CreateDocumentAnnotationComment,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
|
||||
if (!thread) throw notFound("Annotation thread not found");
|
||||
const now = new Date();
|
||||
const [comment] = await tx
|
||||
.insert(documentAnnotationComments)
|
||||
.values({
|
||||
companyId: thread.companyId,
|
||||
threadId: thread.id,
|
||||
issueId: null,
|
||||
routineId: thread.routineId,
|
||||
documentId: thread.documentId,
|
||||
body: input.body,
|
||||
authorType: actor.actorType,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(commentSelect);
|
||||
await tx
|
||||
.update(documentAnnotationThreads)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(documentAnnotationThreads.id, thread.id));
|
||||
return comment;
|
||||
}),
|
||||
|
||||
cleanupForIssueCommentDeletion: async (
|
||||
issueId: string,
|
||||
issueCommentId: string,
|
||||
|
|
@ -425,6 +639,40 @@ export function documentAnnotationService(db: Db) {
|
|||
return updated;
|
||||
}),
|
||||
|
||||
updateRoutineThread: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
input: UpdateDocumentAnnotationThread,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
|
||||
if (!thread) throw notFound("Annotation thread not found");
|
||||
if (!input.status || input.status === thread.status) return thread;
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await tx
|
||||
.update(documentAnnotationThreads)
|
||||
.set(input.status === "resolved"
|
||||
? {
|
||||
status: "resolved",
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByUserId: actor.userId ?? null,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
: {
|
||||
status: "open",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documentAnnotationThreads.id, thread.id))
|
||||
.returning(threadSelect);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
remapOpenThreadsForDocument: async (input: {
|
||||
issueId: string;
|
||||
key: string;
|
||||
|
|
@ -500,6 +748,81 @@ export function documentAnnotationService(db: Db) {
|
|||
return changed;
|
||||
}),
|
||||
|
||||
remapOpenThreadsForRoutineDocument: async (input: {
|
||||
routineId: string;
|
||||
key: string;
|
||||
documentId: string;
|
||||
nextRevisionId: string | null;
|
||||
nextRevisionNumber: number;
|
||||
nextBody: string;
|
||||
}) => db.transaction(async (tx) => {
|
||||
const threads: DocumentAnnotationThread[] = await tx
|
||||
.select(threadSelect)
|
||||
.from(documentAnnotationThreads)
|
||||
.where(and(
|
||||
eq(documentAnnotationThreads.routineId, input.routineId),
|
||||
eq(documentAnnotationThreads.documentId, input.documentId),
|
||||
eq(documentAnnotationThreads.status, "open"),
|
||||
));
|
||||
const changed = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const thread of threads) {
|
||||
if (thread.currentRevisionId === input.nextRevisionId) continue;
|
||||
const previousAnchor = snapshotFromThread(thread);
|
||||
const remap = remapDocumentAnchor({
|
||||
previousAnchor,
|
||||
nextMarkdown: input.nextBody,
|
||||
});
|
||||
const nextAnchor = remap.anchor;
|
||||
const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : thread.anchorSelector;
|
||||
const [updated] = await tx
|
||||
.update(documentAnnotationThreads)
|
||||
.set({
|
||||
currentRevisionId: input.nextRevisionId,
|
||||
currentRevisionNumber: input.nextRevisionNumber,
|
||||
anchorState: remap.anchorState,
|
||||
anchorConfidence: remap.confidence,
|
||||
...(nextAnchor
|
||||
? {
|
||||
selectedText: nextAnchor.selectedText,
|
||||
prefixText: nextAnchor.prefixText,
|
||||
suffixText: nextAnchor.suffixText,
|
||||
normalizedStart: nextAnchor.normalizedStart,
|
||||
normalizedEnd: nextAnchor.normalizedEnd,
|
||||
markdownStart: nextAnchor.markdownStart,
|
||||
markdownEnd: nextAnchor.markdownEnd,
|
||||
}
|
||||
: {}),
|
||||
anchorSelector: nextSelector,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documentAnnotationThreads.id, thread.id))
|
||||
.returning(threadSelect);
|
||||
const [snapshot] = await tx
|
||||
.insert(documentAnnotationAnchorSnapshots)
|
||||
.values({
|
||||
companyId: thread.companyId,
|
||||
threadId: thread.id,
|
||||
documentId: thread.documentId,
|
||||
fromRevisionId: thread.currentRevisionId,
|
||||
fromRevisionNumber: thread.currentRevisionNumber,
|
||||
toRevisionId: input.nextRevisionId,
|
||||
toRevisionNumber: input.nextRevisionNumber,
|
||||
previousAnchor,
|
||||
nextAnchor,
|
||||
anchorState: remap.anchorState,
|
||||
anchorConfidence: remap.confidence,
|
||||
failureReason: remap.anchor ? null : remap.reason,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
changed.push({ thread: updated, snapshot });
|
||||
}
|
||||
|
||||
return changed;
|
||||
}),
|
||||
|
||||
selectorToAnchorSnapshot,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1419,6 +1419,20 @@ const heartbeatRunListColumns = {
|
|||
updatedAt: heartbeatRuns.updatedAt,
|
||||
} as const;
|
||||
|
||||
const heartbeatRunSummaryListColumns = {
|
||||
...heartbeatRunListColumns,
|
||||
usageJson: sql<Record<string, unknown> | null>`NULL`.as("usageJson"),
|
||||
sessionIdBefore: sql<string | null>`NULL`.as("sessionIdBefore"),
|
||||
sessionIdAfter: sql<string | null>`NULL`.as("sessionIdAfter"),
|
||||
logStore: sql<string | null>`NULL`.as("logStore"),
|
||||
logRef: sql<string | null>`NULL`.as("logRef"),
|
||||
logSha256: sql<string | null>`NULL`.as("logSha256"),
|
||||
externalRunId: sql<string | null>`NULL`.as("externalRunId"),
|
||||
processPid: sql<number | null>`NULL`.as("processPid"),
|
||||
processGroupId: sql<number | null>`NULL`.as("processGroupId"),
|
||||
resultJson: sql<Record<string, unknown> | null>`NULL`.as("resultJson"),
|
||||
} as const;
|
||||
|
||||
const heartbeatRunListContextColumns = {
|
||||
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"),
|
||||
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
|
||||
|
|
@ -8831,6 +8845,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
companyId: agent.companyId,
|
||||
heartbeatRunId: run.id,
|
||||
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
||||
issueId,
|
||||
});
|
||||
const executionWorkspaceBase = {
|
||||
baseCwd: resolvedWorkspace.cwd,
|
||||
|
|
@ -11939,11 +11954,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
|
||||
return {
|
||||
list: async (companyId: string, agentId?: string, limit?: number) => {
|
||||
list: async (
|
||||
companyId: string,
|
||||
agentId?: string,
|
||||
limit?: number,
|
||||
options: { summary?: boolean } = {},
|
||||
) => {
|
||||
const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase();
|
||||
const summary = options.summary === true;
|
||||
const query = db
|
||||
.select(
|
||||
safeForLegacyEncoding
|
||||
summary
|
||||
? {
|
||||
...heartbeatRunSummaryListColumns,
|
||||
...heartbeatRunListContextColumns,
|
||||
}
|
||||
: safeForLegacyEncoding
|
||||
? {
|
||||
...heartbeatRunListColumns,
|
||||
error: sql<string | null>`NULL`.as("error"),
|
||||
|
|
@ -12004,7 +12030,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
wakeSource: contextWakeSource,
|
||||
wakeTriggerDetail: contextWakeTriggerDetail,
|
||||
}),
|
||||
resultJson: safeForLegacyEncoding
|
||||
resultJson: safeForLegacyEncoding || summary
|
||||
? null
|
||||
: summarizeHeartbeatRunListResultJson({
|
||||
summary: resultSummary,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import {
|
|||
suggestTasksResultSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
|
||||
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
|
||||
|
||||
type InteractionActor = {
|
||||
agentId?: string | null;
|
||||
|
|
@ -541,7 +541,10 @@ export function issueThreadInteractionService(db: Db) {
|
|||
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
||||
db: Pick<Db, "select">;
|
||||
issue: { id: string; companyId: string };
|
||||
sourceRunId: string | null;
|
||||
}) {
|
||||
if (!args.sourceRunId) return;
|
||||
|
||||
const executionWorkspaceId = await args.db
|
||||
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
||||
.from(issues)
|
||||
|
|
@ -550,17 +553,18 @@ export function issueThreadInteractionService(db: Db) {
|
|||
|
||||
if (!executionWorkspaceId) return;
|
||||
|
||||
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
|
||||
const isFinalized = await runWorkspaceIsFinalized(
|
||||
args.db,
|
||||
args.issue.companyId,
|
||||
[executionWorkspaceId],
|
||||
executionWorkspaceId,
|
||||
args.sourceRunId,
|
||||
);
|
||||
if (!unfinalized.has(executionWorkspaceId)) return;
|
||||
if (isFinalized) return;
|
||||
|
||||
throw conflict(
|
||||
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
|
||||
"Cannot accept interaction: the run that created this interaction has not finished syncing its workspace. "
|
||||
+ "Retry once the local worktree has finished syncing.",
|
||||
{ executionWorkspaceId },
|
||||
{ executionWorkspaceId, sourceRunId: args.sourceRunId },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -870,7 +874,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||
// workspace_finalize gate (PAPA-440) does not apply here.
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||
case "request_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
|
|
@ -884,7 +888,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||
};
|
||||
}
|
||||
case "request_checkbox_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
|
|
|
|||
|
|
@ -689,6 +689,94 @@ export async function listUnfinalizedExecutionWorkspaceIds(
|
|||
return unfinalized;
|
||||
}
|
||||
|
||||
async function listPendingFinalizeBlockerIssueIds(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>,
|
||||
): Promise<Set<string>> {
|
||||
const pending = new Set<string>();
|
||||
const blockerIssueIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId))];
|
||||
const executionWorkspaceIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId))];
|
||||
if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0) return pending;
|
||||
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
issueId: workspaceOperations.issueId,
|
||||
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
inArray(workspaceOperations.issueId, blockerIssueIds),
|
||||
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
|
||||
),
|
||||
);
|
||||
|
||||
const latestByBlockerWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
|
||||
for (const row of rows) {
|
||||
if (!row.issueId || !row.executionWorkspaceId) continue;
|
||||
const key = `${row.issueId}:${row.executionWorkspaceId}`;
|
||||
const current = latestByBlockerWorkspace.get(key);
|
||||
if (!current || row.startedAt > current.startedAt) {
|
||||
latestByBlockerWorkspace.set(key, {
|
||||
phase: row.phase,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const pair of blockerWorkspacePairs) {
|
||||
const latest = latestByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`);
|
||||
if (!latest) continue; // no attributed ops recorded -> nothing to finalize for this blocker
|
||||
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
|
||||
pending.add(pair.blockerIssueId);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a specific run's operations on a specific execution workspace
|
||||
* reached the workspace_finalize barrier.
|
||||
*
|
||||
* Runs with no operations on the workspace are considered finalized because
|
||||
* they never touched the workspace state that accept/review gates protect.
|
||||
*/
|
||||
export async function runWorkspaceIsFinalized(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
executionWorkspaceId: string,
|
||||
runId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId),
|
||||
eq(workspaceOperations.heartbeatRunId, runId),
|
||||
),
|
||||
);
|
||||
|
||||
let latest: { phase: string; status: string; startedAt: Date } | null = null;
|
||||
for (const row of rows) {
|
||||
if (!latest || row.startedAt > latest.startedAt) latest = row;
|
||||
}
|
||||
|
||||
if (!latest) return true;
|
||||
return latest.phase === "workspace_finalize" && latest.status === "succeeded";
|
||||
}
|
||||
|
||||
async function listIssueDependencyReadinessMap(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
|
|
@ -718,19 +806,22 @@ async function listIssueDependencyReadinessMap(
|
|||
),
|
||||
);
|
||||
|
||||
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
|
||||
// Collect issue/workspace pairs of "done" blockers — these are the only ones
|
||||
// subject to the workspace-finalize barrier. Blockers that aren't done already
|
||||
// mark the dependent as not-ready and don't need a finalize check.
|
||||
const doneBlockerWorkspaceIds = new Set<string>();
|
||||
const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = [];
|
||||
for (const row of blockerRows) {
|
||||
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
|
||||
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
|
||||
doneBlockerWorkspacePairs.push({
|
||||
blockerIssueId: row.blockerIssueId,
|
||||
executionWorkspaceId: row.blockerExecutionWorkspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
|
||||
const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds(
|
||||
dbOrTx,
|
||||
companyId,
|
||||
[...doneBlockerWorkspaceIds],
|
||||
doneBlockerWorkspacePairs,
|
||||
);
|
||||
|
||||
for (const row of blockerRows) {
|
||||
|
|
@ -745,7 +836,7 @@ async function listIssueDependencyReadinessMap(
|
|||
current.isDependencyReady = false;
|
||||
} else if (
|
||||
row.blockerExecutionWorkspaceId &&
|
||||
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
|
||||
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId)
|
||||
) {
|
||||
// Workspace-finalize barrier: the blocker's most recent run on its
|
||||
// execution workspace hasn't recorded a successful workspace_finalize.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
companySecretBindings,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
|
|
@ -17,6 +19,7 @@ import {
|
|||
projects,
|
||||
routineRevisions,
|
||||
routineRuns,
|
||||
routineDocuments,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
|
|
@ -25,6 +28,7 @@ import type {
|
|||
CreateRoutineTrigger,
|
||||
Routine,
|
||||
RoutineDetail,
|
||||
RoutineDescriptionDocument,
|
||||
RoutineListItem,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineRevision,
|
||||
|
|
@ -80,6 +84,8 @@ type Actor = { agentId?: string | null; userId?: string | null; runId?: string |
|
|||
type RoutineRow = typeof routines.$inferSelect;
|
||||
type RoutineTriggerRow = typeof routineTriggers.$inferSelect;
|
||||
|
||||
const ROUTINE_DESCRIPTION_DOCUMENT_KEY = "description" as const;
|
||||
|
||||
interface RoutineTriggerSecretRestoreMaterial extends RoutineTriggerSecretMaterial {
|
||||
triggerId: string;
|
||||
}
|
||||
|
|
@ -488,6 +494,42 @@ function mapRoutineRevision(row: typeof routineRevisions.$inferSelect): RoutineR
|
|||
};
|
||||
}
|
||||
|
||||
function mapRoutineDescriptionDocument(row: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
routineId: string;
|
||||
key: string;
|
||||
title: string | null;
|
||||
format: string;
|
||||
latestBody: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): RoutineDescriptionDocument {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
routineId: row.routineId,
|
||||
key: ROUTINE_DESCRIPTION_DOCUMENT_KEY,
|
||||
title: row.title,
|
||||
format: "markdown",
|
||||
body: row.latestBody,
|
||||
latestRevisionId: row.latestRevisionId,
|
||||
latestRevisionNumber: row.latestRevisionNumber,
|
||||
createdByAgentId: row.createdByAgentId,
|
||||
createdByUserId: row.createdByUserId,
|
||||
updatedByAgentId: row.updatedByAgentId,
|
||||
updatedByUserId: row.updatedByUserId,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function routineService(
|
||||
db: Db,
|
||||
deps: {
|
||||
|
|
@ -574,6 +616,163 @@ export function routineService(
|
|||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getRoutineDescriptionDocument(
|
||||
routineId: string,
|
||||
executor: Db | any = db,
|
||||
): Promise<RoutineDescriptionDocument | null> {
|
||||
const row = await executor
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
routineId: routineDocuments.routineId,
|
||||
key: routineDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(routineDocuments)
|
||||
.innerJoin(documents, eq(routineDocuments.documentId, documents.id))
|
||||
.where(and(
|
||||
eq(routineDocuments.routineId, routineId),
|
||||
eq(routineDocuments.key, ROUTINE_DESCRIPTION_DOCUMENT_KEY),
|
||||
))
|
||||
.then((rows: any[]) => rows[0] ?? null);
|
||||
return row ? mapRoutineDescriptionDocument(row) : null;
|
||||
}
|
||||
|
||||
async function upsertRoutineDescriptionDocument(
|
||||
executor: Db | any,
|
||||
routine: RoutineRow,
|
||||
actor: Actor,
|
||||
options: { changeSummary?: string | null } = {},
|
||||
): Promise<RoutineDescriptionDocument> {
|
||||
const now = new Date();
|
||||
const body = routine.description ?? "";
|
||||
const existing = await getRoutineDescriptionDocument(routine.id, executor);
|
||||
|
||||
if (existing) {
|
||||
if (existing.body === body) return existing;
|
||||
const nextRevisionNumber = existing.latestRevisionNumber + 1;
|
||||
const [revision] = await executor
|
||||
.insert(documentRevisions)
|
||||
.values({
|
||||
companyId: routine.companyId,
|
||||
documentId: existing.id,
|
||||
revisionNumber: nextRevisionNumber,
|
||||
title: "Routine description",
|
||||
format: "markdown",
|
||||
body,
|
||||
changeSummary: options.changeSummary ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await executor
|
||||
.update(documents)
|
||||
.set({
|
||||
title: "Routine description",
|
||||
format: "markdown",
|
||||
latestBody: body,
|
||||
latestRevisionId: revision.id,
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
updatedByUserId: actor.userId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documents.id, existing.id));
|
||||
await executor
|
||||
.update(routineDocuments)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(routineDocuments.documentId, existing.id));
|
||||
|
||||
return {
|
||||
...existing,
|
||||
title: "Routine description",
|
||||
body,
|
||||
latestRevisionId: revision.id,
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
updatedByUserId: actor.userId ?? null,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
const [document] = await executor
|
||||
.insert(documents)
|
||||
.values({
|
||||
companyId: routine.companyId,
|
||||
title: "Routine description",
|
||||
format: "markdown",
|
||||
latestBody: body,
|
||||
latestRevisionId: null,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
updatedByUserId: actor.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
const [revision] = await executor
|
||||
.insert(documentRevisions)
|
||||
.values({
|
||||
companyId: routine.companyId,
|
||||
documentId: document.id,
|
||||
revisionNumber: 1,
|
||||
title: "Routine description",
|
||||
format: "markdown",
|
||||
body,
|
||||
changeSummary: options.changeSummary ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
await executor
|
||||
.update(documents)
|
||||
.set({ latestRevisionId: revision.id })
|
||||
.where(eq(documents.id, document.id));
|
||||
await executor.insert(routineDocuments).values({
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
documentId: document.id,
|
||||
key: ROUTINE_DESCRIPTION_DOCUMENT_KEY,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
id: document.id,
|
||||
companyId: routine.companyId,
|
||||
routineId: routine.id,
|
||||
key: ROUTINE_DESCRIPTION_DOCUMENT_KEY,
|
||||
title: document.title,
|
||||
format: "markdown",
|
||||
body,
|
||||
latestRevisionId: revision.id,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: document.createdByAgentId,
|
||||
createdByUserId: document.createdByUserId,
|
||||
updatedByAgentId: document.updatedByAgentId,
|
||||
updatedByUserId: document.updatedByUserId,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function appendRoutineRevision(
|
||||
executor: Db,
|
||||
routine: RoutineRow,
|
||||
|
|
@ -614,8 +813,18 @@ export function routineService(
|
|||
.where(eq(routines.id, routine.id))
|
||||
.returning();
|
||||
|
||||
const routineForDocument = updatedRoutine ?? {
|
||||
...routine,
|
||||
latestRevisionId: revision.id,
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedAt: now,
|
||||
};
|
||||
await upsertRoutineDescriptionDocument(executor, routineForDocument, actor, {
|
||||
changeSummary: options.changeSummary ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
routine: updatedRoutine ?? { ...routine, latestRevisionId: revision.id, latestRevisionNumber: nextRevisionNumber, updatedAt: now },
|
||||
routine: routineForDocument,
|
||||
revision: mapRoutineRevision(revision),
|
||||
};
|
||||
}
|
||||
|
|
@ -1488,7 +1697,7 @@ export function routineService(
|
|||
getDetail: async (id: string): Promise<RoutineDetail | null> => {
|
||||
const row = await getRoutineById(id);
|
||||
if (!row) return null;
|
||||
const [project, assignee, parentIssue, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([
|
||||
const [project, assignee, parentIssue, descriptionDocument, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([
|
||||
row.projectId
|
||||
? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null)
|
||||
: null,
|
||||
|
|
@ -1496,6 +1705,7 @@ export function routineService(
|
|||
? db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null)
|
||||
: null,
|
||||
row.parentIssueId ? issueSvc.getById(row.parentIssueId) : null,
|
||||
getRoutineDescriptionDocument(row.id),
|
||||
db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)),
|
||||
db
|
||||
.select({
|
||||
|
|
@ -1578,12 +1788,15 @@ export function routineService(
|
|||
project,
|
||||
assignee,
|
||||
parentIssue,
|
||||
descriptionDocument,
|
||||
triggers: triggers as RoutineTrigger[],
|
||||
recentRuns,
|
||||
activeIssue,
|
||||
};
|
||||
},
|
||||
|
||||
getDescriptionDocument: async (routineId: string) => getRoutineDescriptionDocument(routineId),
|
||||
|
||||
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
||||
await assertProject(companyId, input.projectId ?? null);
|
||||
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation {
|
|||
companyId: row.companyId,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
heartbeatRunId: row.heartbeatRunId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
phase: row.phase as WorkspaceOperationPhase,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
|
|
@ -89,6 +90,7 @@ export function workspaceOperationService(db: Db) {
|
|||
companyId: string;
|
||||
heartbeatRunId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
}): WorkspaceOperationRecorder {
|
||||
let executionWorkspaceId = input.executionWorkspaceId ?? null;
|
||||
const createdIds: string[] = [];
|
||||
|
|
@ -136,6 +138,7 @@ export function workspaceOperationService(db: Db) {
|
|||
companyId: input.companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
phase: recordInput.phase,
|
||||
command: recordInput.command ?? null,
|
||||
cwd: recordInput.cwd ?? null,
|
||||
|
|
|
|||
|
|
@ -11,27 +11,50 @@ import { api } from "./client";
|
|||
|
||||
export type DocumentAnnotationListFilter = "open" | "resolved" | "all";
|
||||
|
||||
export type DocumentAnnotationTarget =
|
||||
| { kind: "issue"; issueId: string; documentKey: string }
|
||||
| { kind: "routine"; routineId: string; documentKey: "description" };
|
||||
|
||||
function issueTarget(issueId: string, documentKey: string): DocumentAnnotationTarget {
|
||||
return { kind: "issue", issueId, documentKey };
|
||||
}
|
||||
|
||||
function targetBasePath(target: DocumentAnnotationTarget) {
|
||||
if (target.kind === "routine") {
|
||||
return `/routines/${target.routineId}/description/annotations`;
|
||||
}
|
||||
return `/issues/${target.issueId}/documents/${encodeURIComponent(target.documentKey)}/annotations`;
|
||||
}
|
||||
|
||||
export const documentAnnotationsApi = {
|
||||
list: (
|
||||
issueId: string,
|
||||
key: string,
|
||||
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
|
||||
) => documentAnnotationsApi.listForTarget(issueTarget(issueId, key), options),
|
||||
listForTarget: (
|
||||
target: DocumentAnnotationTarget,
|
||||
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.status) params.set("status", options.status);
|
||||
if (options.includeComments) params.set("includeComments", "true");
|
||||
const qs = params.toString();
|
||||
return api.get<DocumentAnnotationThreadWithComments[]>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`,
|
||||
`${targetBasePath(target)}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
get: (issueId: string, key: string, threadId: string) =>
|
||||
documentAnnotationsApi.getForTarget(issueTarget(issueId, key), threadId),
|
||||
getForTarget: (target: DocumentAnnotationTarget, threadId: string) =>
|
||||
api.get<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
`${targetBasePath(target)}/${threadId}`,
|
||||
),
|
||||
create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) =>
|
||||
documentAnnotationsApi.createForTarget(issueTarget(issueId, key), data),
|
||||
createForTarget: (target: DocumentAnnotationTarget, data: CreateDocumentAnnotationThreadRequest) =>
|
||||
api.post<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`,
|
||||
targetBasePath(target),
|
||||
data,
|
||||
),
|
||||
addComment: (
|
||||
|
|
@ -39,9 +62,14 @@ export const documentAnnotationsApi = {
|
|||
key: string,
|
||||
threadId: string,
|
||||
data: CreateDocumentAnnotationCommentRequest,
|
||||
) => documentAnnotationsApi.addCommentForTarget(issueTarget(issueId, key), threadId, data),
|
||||
addCommentForTarget: (
|
||||
target: DocumentAnnotationTarget,
|
||||
threadId: string,
|
||||
data: CreateDocumentAnnotationCommentRequest,
|
||||
) =>
|
||||
api.post<DocumentAnnotationComment>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`,
|
||||
`${targetBasePath(target)}/${threadId}/comments`,
|
||||
data,
|
||||
),
|
||||
updateStatus: (
|
||||
|
|
@ -49,10 +77,15 @@ export const documentAnnotationsApi = {
|
|||
key: string,
|
||||
threadId: string,
|
||||
status: DocumentAnnotationThreadStatus,
|
||||
) => documentAnnotationsApi.updateStatusForTarget(issueTarget(issueId, key), threadId, status),
|
||||
updateStatusForTarget: (
|
||||
target: DocumentAnnotationTarget,
|
||||
threadId: string,
|
||||
status: DocumentAnnotationThreadStatus,
|
||||
) => {
|
||||
const payload: UpdateDocumentAnnotationThreadRequest = { status };
|
||||
return api.patch<DocumentAnnotationThread>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
`${targetBasePath(target)}/${threadId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,25 @@ vi.mock("./client", () => ({
|
|||
|
||||
import { heartbeatsApi } from "./heartbeats";
|
||||
|
||||
describe("heartbeatsApi.list", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("requests summary rows for hot-path history consumers", async () => {
|
||||
await heartbeatsApi.list("company-1", undefined, 200, { summary: true });
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/heartbeat-runs?limit=200&summary=true");
|
||||
});
|
||||
|
||||
it("keeps full row requests as the default for run-history screens", async () => {
|
||||
await heartbeatsApi.list("company-1", "agent-1", 25);
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/heartbeat-runs?agentId=agent-1&limit=25");
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeatsApi.liveRunsForCompany", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
|
|
|
|||
|
|
@ -70,11 +70,16 @@ export interface WatchdogDecisionInput {
|
|||
snoozedUntil?: string | null;
|
||||
}
|
||||
|
||||
export interface HeartbeatRunListOptions {
|
||||
summary?: boolean;
|
||||
}
|
||||
|
||||
export const heartbeatsApi = {
|
||||
list: (companyId: string, agentId?: string, limit?: number) => {
|
||||
list: (companyId: string, agentId?: string, limit?: number, options: HeartbeatRunListOptions = {}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (agentId) searchParams.set("agentId", agentId);
|
||||
if (limit) searchParams.set("limit", String(limit));
|
||||
if (options.summary) searchParams.set("summary", "true");
|
||||
const qs = searchParams.toString();
|
||||
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function ArtifactFileChip({
|
|||
: `Workspace file ${workspaceFileRef.displayPath}${lineSuffix}`);
|
||||
|
||||
const classNames = cn(
|
||||
"paperclip-artifact-file-chip inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
"paperclip-artifact-file-chip inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-middle no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
canOpen ? "cursor-pointer" : null,
|
||||
className,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -48,8 +48,17 @@ export interface AnnotationLayerProps {
|
|||
* pending anchor for the keyboard shortcut path.
|
||||
*/
|
||||
captureSelectionRequestId?: number;
|
||||
/**
|
||||
* Text of a comment currently being composed. We keep this segment brightly
|
||||
* highlighted in the document even after the native browser selection is lost
|
||||
* (e.g. once focus moves into the composer textarea).
|
||||
*/
|
||||
pendingHighlightText?: string | null;
|
||||
}
|
||||
|
||||
/** Synthetic thread id used to render the in-progress (pending) comment highlight. */
|
||||
const PENDING_HIGHLIGHT_THREAD_ID = "__paperclip-pending-annotation__";
|
||||
|
||||
interface HighlightRect {
|
||||
threadId: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
|
|
@ -60,6 +69,8 @@ interface HighlightRect {
|
|||
height: number;
|
||||
/** True for the last rect of this thread (used to anchor a glyph at the run end). */
|
||||
isTail: boolean;
|
||||
/** True when this run should render with the brighter focused/pending treatment. */
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
interface ToolbarPosition {
|
||||
|
|
@ -203,8 +214,10 @@ export function DocumentAnnotationLayer({
|
|||
newCommentDisabledReason = null,
|
||||
hideResolved = true,
|
||||
captureSelectionRequestId,
|
||||
pendingHighlightText = null,
|
||||
}: AnnotationLayerProps) {
|
||||
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
|
||||
const [hoveredThreadId, setHoveredThreadId] = useState<string | null>(null);
|
||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastCaptureSelectionRequestIdRef = useRef<number>(0);
|
||||
|
|
@ -231,19 +244,17 @@ export function DocumentAnnotationLayer({
|
|||
const overlayRect = overlay.getBoundingClientRect();
|
||||
const next: HighlightRect[] = [];
|
||||
const nativeRanges = emptyNativeHighlightRanges();
|
||||
for (const thread of visibleThreads) {
|
||||
if (thread.anchorState === "orphaned") continue;
|
||||
const isFocused = thread.id === focusedThreadId;
|
||||
const isStale = thread.anchorState === "stale";
|
||||
const isResolved = thread.status === "resolved";
|
||||
const nativeKind = nativeHighlightKind({
|
||||
focused: isFocused,
|
||||
stale: isStale,
|
||||
resolved: isResolved,
|
||||
});
|
||||
const pushRunRects = (run: {
|
||||
threadId: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
anchorState: DocumentAnnotationAnchorState;
|
||||
focused: boolean;
|
||||
selectedText: string;
|
||||
nativeKind: NativeHighlightKind;
|
||||
}) => {
|
||||
const ranges = rangesForNormalizedSpan({
|
||||
container,
|
||||
selectedText: thread.selectedText,
|
||||
selectedText: run.selectedText,
|
||||
});
|
||||
const startIndex = next.length;
|
||||
for (const range of ranges) {
|
||||
|
|
@ -256,9 +267,10 @@ export function DocumentAnnotationLayer({
|
|||
if (!visibleRect) continue;
|
||||
rangeIsVisible = true;
|
||||
next.push({
|
||||
threadId: thread.id,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
threadId: run.threadId,
|
||||
status: run.status,
|
||||
anchorState: run.anchorState,
|
||||
focused: run.focused,
|
||||
top: visibleRect.top - overlayRect.top,
|
||||
left: visibleRect.left - overlayRect.left,
|
||||
width: visibleRect.width,
|
||||
|
|
@ -266,15 +278,41 @@ export function DocumentAnnotationLayer({
|
|||
isTail: false,
|
||||
});
|
||||
}
|
||||
if (rangeIsVisible) nativeRanges[nativeKind].push(range);
|
||||
if (rangeIsVisible) nativeRanges[run.nativeKind].push(range);
|
||||
}
|
||||
if (next.length > startIndex) {
|
||||
next[next.length - 1].isTail = true;
|
||||
}
|
||||
};
|
||||
for (const thread of visibleThreads) {
|
||||
if (thread.anchorState === "orphaned") continue;
|
||||
const isFocused = thread.id === focusedThreadId;
|
||||
const isStale = thread.anchorState === "stale";
|
||||
const isResolved = thread.status === "resolved";
|
||||
pushRunRects({
|
||||
threadId: thread.id,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
focused: isFocused,
|
||||
selectedText: thread.selectedText,
|
||||
nativeKind: nativeHighlightKind({ focused: isFocused, stale: isStale, resolved: isResolved }),
|
||||
});
|
||||
}
|
||||
// Keep the in-progress (pending) comment selection brightly highlighted so the
|
||||
// segment stays anchored in the document while the composer is open.
|
||||
if (pendingHighlightText && pendingHighlightText.trim().length > 0) {
|
||||
pushRunRects({
|
||||
threadId: PENDING_HIGHLIGHT_THREAD_ID,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
focused: true,
|
||||
selectedText: pendingHighlightText,
|
||||
nativeKind: "focused",
|
||||
});
|
||||
}
|
||||
setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges);
|
||||
setHighlightRects(next);
|
||||
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]);
|
||||
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, pendingHighlightText, visibleThreads]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
computeHighlightRects();
|
||||
|
|
@ -398,7 +436,7 @@ export function DocumentAnnotationLayer({
|
|||
<div className="paperclip-doc-annotation-visual-layer pointer-events-none absolute inset-0 z-0" aria-hidden="true">
|
||||
<div className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
const isFocused = rect.focused;
|
||||
const isStale = rect.anchorState === "stale";
|
||||
const isResolved = rect.status === "resolved";
|
||||
return (
|
||||
|
|
@ -437,7 +475,9 @@ export function DocumentAnnotationLayer({
|
|||
>
|
||||
<div ref={overlayRef} className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
if (rect.threadId === PENDING_HIGHLIGHT_THREAD_ID) return null;
|
||||
const isFocused = rect.focused;
|
||||
const isHovered = rect.threadId === hoveredThreadId;
|
||||
return (
|
||||
<button
|
||||
key={`${rect.threadId}-${index}`}
|
||||
|
|
@ -446,9 +486,12 @@ export function DocumentAnnotationLayer({
|
|||
data-anchor-state={rect.anchorState}
|
||||
data-status={rect.status}
|
||||
data-focused={isFocused || undefined}
|
||||
data-hovered={isHovered || undefined}
|
||||
aria-label="Open annotation thread"
|
||||
className={cn(
|
||||
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent",
|
||||
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent transition-colors",
|
||||
// Tint the run on hover so it's obvious which highlight you're over.
|
||||
isHovered && "bg-amber-400/40 dark:bg-amber-300/30",
|
||||
isFocused && "ring-1 ring-transparent",
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -457,6 +500,10 @@ export function DocumentAnnotationLayer({
|
|||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredThreadId(rect.threadId)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredThreadId((current) => (current === rect.threadId ? null : current))
|
||||
}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onThreadFocus(rect.threadId);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationComment,
|
||||
DocumentAnnotationThreadStatus,
|
||||
|
|
@ -13,7 +13,6 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -22,26 +21,23 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { deriveInitials } from "./Identity";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import type { PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
type AnnotationFilter = "open" | "resolved" | "stale" | "orphan";
|
||||
|
||||
const FILTERS: { id: AnnotationFilter; label: string }[] = [
|
||||
{ id: "open", label: "Open" },
|
||||
{ id: "resolved", label: "Resolved" },
|
||||
{ id: "stale", label: "Stale" },
|
||||
{ id: "orphan", label: "Orphaned" },
|
||||
];
|
||||
|
||||
export interface AnnotationPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
issueId: string;
|
||||
target?: DocumentAnnotationTarget;
|
||||
issueId?: string;
|
||||
documentKey: string;
|
||||
documentRevisionNumber: number;
|
||||
baseRevisionId: string | null;
|
||||
|
|
@ -63,7 +59,7 @@ export interface AnnotationPanelProps {
|
|||
desktopWidth?: number;
|
||||
className?: string;
|
||||
/** Resolve `<authorAgentId>` to a display name. */
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
/** Resolve `<authorUserId>` to a display name. */
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}
|
||||
|
|
@ -107,76 +103,195 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
|||
|
||||
function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<AnnotationFilter>("open");
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
|
||||
const annotationTarget = useMemo<DocumentAnnotationTarget>(() => {
|
||||
if (props.target) return props.target;
|
||||
if (!props.issueId) throw new Error("Document annotation panel requires an annotation target.");
|
||||
return { kind: "issue", issueId: props.issueId, documentKey: props.documentKey };
|
||||
}, [props.documentKey, props.issueId, props.target]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return props.threads.filter((thread) => {
|
||||
if (filter === "open") return thread.status === "open" && thread.anchorState !== "orphaned";
|
||||
if (filter === "resolved") return thread.status === "resolved";
|
||||
if (filter === "stale") return thread.anchorState === "stale";
|
||||
if (filter === "orphan") return thread.anchorState === "orphaned";
|
||||
return true;
|
||||
});
|
||||
}, [props.threads, filter]);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const currentUser = useMemo(() => {
|
||||
const user = session?.user;
|
||||
return {
|
||||
id: user?.id ?? null,
|
||||
name: user?.name?.trim() || user?.email?.trim() || "You",
|
||||
image: user?.image ?? null,
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result = { open: 0, resolved: 0, stale: 0, orphan: 0 };
|
||||
for (const thread of props.threads) {
|
||||
if (thread.status === "resolved") result.resolved += 1;
|
||||
if (thread.anchorState === "stale") result.stale += 1;
|
||||
if (thread.anchorState === "orphaned") result.orphan += 1;
|
||||
if (thread.status === "open" && thread.anchorState !== "orphaned") result.open += 1;
|
||||
}
|
||||
return result;
|
||||
}, [props.threads]);
|
||||
// Show every thread that can be anchored in the document (orphaned threads have
|
||||
// lost their anchor). Filters were removed in favour of a single simple list.
|
||||
// Sort in document order (top-to-bottom) — not by recency — so the comment list
|
||||
// stays congruent with the highlights as you scroll the document.
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
props.threads
|
||||
.filter((thread) => thread.anchorState !== "orphaned")
|
||||
.sort((a, b) =>
|
||||
(a.normalizedStart - b.normalizedStart)
|
||||
|| (a.markdownStart - b.markdownStart)
|
||||
|| (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())),
|
||||
[props.threads],
|
||||
);
|
||||
|
||||
const annotationsQueryKey = useMemo(
|
||||
() => annotationTarget.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"),
|
||||
[annotationTarget],
|
||||
);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === props.issueId
|
||||
&& query.queryKey[3] === props.documentKey,
|
||||
predicate: (query) => {
|
||||
if (!Array.isArray(query.queryKey)) return false;
|
||||
if (annotationTarget.kind === "routine") {
|
||||
return query.queryKey[0] === "routines"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === annotationTarget.routineId
|
||||
&& query.queryKey[3] === annotationTarget.documentKey;
|
||||
}
|
||||
return query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === annotationTarget.issueId
|
||||
&& query.queryKey[3] === annotationTarget.documentKey;
|
||||
},
|
||||
});
|
||||
}, [props.documentKey, props.issueId, queryClient]);
|
||||
}, [annotationTarget, queryClient]);
|
||||
|
||||
const createThread = useMutation({
|
||||
mutationFn: async (body: string) => {
|
||||
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
|
||||
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
|
||||
return documentAnnotationsApi.create(props.issueId, props.documentKey, {
|
||||
return documentAnnotationsApi.createForTarget(annotationTarget, {
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
selector: props.pendingAnchor.selector,
|
||||
body,
|
||||
});
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
// Optimistically drop the new thread into the cache so submission feels instant.
|
||||
onMutate: async (body: string) => {
|
||||
const anchor = props.pendingAnchor;
|
||||
if (!anchor || !props.baseRevisionId) return undefined;
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticThread = buildOptimisticThread({
|
||||
body,
|
||||
selectedText: anchor.selectedText,
|
||||
target: annotationTarget,
|
||||
documentKey: annotationTarget.documentKey,
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
normalizedStart: anchor.selector.position.normalizedStart,
|
||||
markdownStart: anchor.selector.position.markdownStart,
|
||||
author: currentUser,
|
||||
});
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => [...(current ?? []), optimisticThread],
|
||||
);
|
||||
props.onFocusThread(optimisticThread.id);
|
||||
return { previous, optimisticId: optimisticThread.id };
|
||||
},
|
||||
onError: (error, _body, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to create comment.");
|
||||
},
|
||||
onSuccess: (thread, _body, context) => {
|
||||
// Swap the optimistic placeholder for the real thread before refetch settles.
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((entry) =>
|
||||
entry.id === context?.optimisticId ? thread : entry,
|
||||
),
|
||||
);
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
setMutationError(null);
|
||||
props.onFocusThread(thread.id);
|
||||
invalidateAll();
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
const addReply = useMutation({
|
||||
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
|
||||
documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }),
|
||||
onSuccess: (_data, variables) => {
|
||||
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
|
||||
invalidateAll();
|
||||
documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }),
|
||||
// Optimistically append the reply so it stays on screen through the round-trip.
|
||||
onMutate: async ({ threadId, body }) => {
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
const optimisticComment = buildOptimisticComment({
|
||||
body,
|
||||
threadId,
|
||||
target: annotationTarget,
|
||||
author: currentUser,
|
||||
});
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((thread) =>
|
||||
thread.id === threadId
|
||||
? { ...thread, comments: [...thread.comments, optimisticComment], updatedAt: optimisticComment.createdAt }
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to add reply.");
|
||||
},
|
||||
onSuccess: (_comment, variables) => {
|
||||
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
|
||||
setMutationError(null);
|
||||
},
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
|
||||
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
|
||||
onSuccess: () => invalidateAll(),
|
||||
documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status),
|
||||
onMutate: async ({ threadId, status }) => {
|
||||
setMutationError(null);
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
annotationsQueryKey,
|
||||
(current) => (current ?? []).map((thread) =>
|
||||
thread.id === threadId ? { ...thread, status } : thread,
|
||||
),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(annotationsQueryKey, context.previous);
|
||||
}
|
||||
setMutationError(error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Failed to update comment status.");
|
||||
},
|
||||
onSuccess: () => setMutationError(null),
|
||||
onSettled: () => invalidateAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -191,28 +306,31 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
|||
}
|
||||
}, [props.open, props.pendingAnchor]);
|
||||
|
||||
// Keep the comment list congruent with the document: when a thread becomes
|
||||
// focused — whether by clicking its highlight in the doc or by adding a new
|
||||
// comment — scroll that card into view in the pane.
|
||||
const listScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.focusedThreadId) return;
|
||||
const focused = props.threads.find((thread) => thread.id === props.focusedThreadId);
|
||||
if (!focused) return;
|
||||
if (focused.anchorState === "orphaned") setFilter("orphan");
|
||||
else if (focused.anchorState === "stale") setFilter("stale");
|
||||
else if (focused.status === "resolved") setFilter("resolved");
|
||||
else setFilter("open");
|
||||
}, [props.focusedThreadId, props.threads]);
|
||||
const container = listScrollRef.current;
|
||||
if (!container) return;
|
||||
const card = container.querySelector<HTMLElement>(
|
||||
`[data-thread-id="${props.focusedThreadId}"]`,
|
||||
);
|
||||
if (card && typeof card.scrollIntoView === "function") {
|
||||
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [props.focusedThreadId, visibleThreads]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
<div
|
||||
data-testid={bodyTestId}
|
||||
className="flex items-start justify-between gap-2 border-b border-border bg-popover px-3 py-2.5"
|
||||
className="flex items-center justify-end gap-1 border-b border-border bg-popover px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-sm font-medium">Comments</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
rev {props.documentRevisionNumber}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
rev {props.documentRevisionNumber}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
|
|
@ -226,31 +344,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
|||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1 border-b border-border bg-popover px-3 py-2">
|
||||
{FILTERS.map((entry) => {
|
||||
const count = counts[entry.id];
|
||||
const isActive = filter === entry.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(entry.id)}
|
||||
data-active={isActive || undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
|
||||
isActive
|
||||
? "border-border bg-muted text-foreground"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{entry.label}</span>
|
||||
<span className={cn("tabular-nums", isActive ? "text-muted-foreground" : "text-muted-foreground/70")}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{props.newCommentDisabled && props.newCommentDisabledReason ? (
|
||||
<p
|
||||
|
|
@ -260,14 +353,18 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
|||
{props.newCommentDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
|
||||
</p>
|
||||
) : (
|
||||
{mutationError ? (
|
||||
<p
|
||||
data-testid="document-annotation-error"
|
||||
className="border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
|
||||
>
|
||||
{mutationError}
|
||||
</p>
|
||||
) : null}
|
||||
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
|
||||
{visibleThreads.length === 0 ? null : (
|
||||
<ul className="space-y-2">
|
||||
{filteredThreads.map((thread) => (
|
||||
{visibleThreads.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
|
|
@ -303,24 +400,40 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
|||
</div>
|
||||
{props.pendingAnchor ? (
|
||||
<div className="border-t border-border bg-popover px-3 py-2">
|
||||
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground">
|
||||
<blockquote className="mb-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{truncate(props.pendingAnchor.selectedText, 160)}
|
||||
</blockquote>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
{currentUser.image ? <AvatarImage src={currentUser.image} alt={currentUser.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(currentUser.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{currentUser.name}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
data-testid="document-annotation-composer"
|
||||
rows={3}
|
||||
value={composerValue}
|
||||
onChange={(event) => setComposerValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (isSubmitShortcut(event)) {
|
||||
event.preventDefault();
|
||||
const body = composerValue.trim();
|
||||
if (
|
||||
body
|
||||
&& !createThread.isPending
|
||||
&& !props.newCommentDisabled
|
||||
&& props.baseRevisionId
|
||||
) {
|
||||
createThread.mutate(body);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Write a comment…"
|
||||
disabled={props.newCommentDisabled}
|
||||
className="resize-y rounded-none text-sm"
|
||||
/>
|
||||
{createThread.isError ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{(createThread.error as Error).message || "Failed to create comment"}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -365,18 +478,10 @@ function ThreadCard(props: {
|
|||
onCopyLink: () => void;
|
||||
pendingReply: boolean;
|
||||
pendingStatus: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const { thread } = props;
|
||||
const statusVariant: { variant: "default" | "outline" | "secondary"; label: string } =
|
||||
thread.status === "resolved"
|
||||
? { variant: "outline", label: "Resolved" }
|
||||
: thread.anchorState === "orphaned"
|
||||
? { variant: "outline", label: "Orphaned" }
|
||||
: thread.anchorState === "stale"
|
||||
? { variant: "outline", label: "Stale" }
|
||||
: { variant: "default", label: "Open" };
|
||||
const latestComment = thread.comments[thread.comments.length - 1];
|
||||
|
||||
return (
|
||||
|
|
@ -389,23 +494,17 @@ function ThreadCard(props: {
|
|||
data-focused={props.expanded || undefined}
|
||||
aria-labelledby={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"rounded-none border border-border bg-background transition-colors",
|
||||
props.expanded && "ring-1 ring-ring/70",
|
||||
"scroll-mt-2 rounded-none border border-border bg-background transition-colors",
|
||||
props.expanded && "ring-2 ring-primary/80 ring-offset-1 ring-offset-popover",
|
||||
thread.status === "resolved" && "bg-muted",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={props.onFocus}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant={statusVariant.variant} className="px-1.5 py-0 text-[10px] uppercase tracking-[0.12em]">
|
||||
{statusVariant.label}
|
||||
</Badge>
|
||||
<span>{relativeTime(thread.updatedAt)}</span>
|
||||
</div>
|
||||
<blockquote
|
||||
id={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground",
|
||||
"mx-3 mt-2 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic leading-5 text-muted-foreground [overflow-wrap:anywhere]",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted",
|
||||
)}
|
||||
>
|
||||
|
|
@ -427,6 +526,14 @@ function ThreadCard(props: {
|
|||
rows={2}
|
||||
value={props.replyDraft}
|
||||
onChange={(event) => props.onReplyChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (isSubmitShortcut(event)) {
|
||||
event.preventDefault();
|
||||
if (props.replyDraft.trim() && !props.pendingReply) {
|
||||
props.onSubmitReply();
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Reply…"
|
||||
className="resize-y rounded-none text-sm"
|
||||
disabled={props.pendingReply}
|
||||
|
|
@ -506,7 +613,7 @@ function CommentRow({
|
|||
}: {
|
||||
comment: DocumentAnnotationComment;
|
||||
focused: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const author = resolveAuthor(comment, { agentMap, userProfileMap });
|
||||
|
|
@ -520,31 +627,49 @@ function CommentRow({
|
|||
)}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium text-foreground">{author.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
{author.role === "agent" ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={author.agentIcon} className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<>
|
||||
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="truncate font-medium text-foreground">{author.name}</span>
|
||||
{author.role === "agent" ? (
|
||||
<span className="ml-1 text-muted-foreground">· agent</span>
|
||||
<span className="text-muted-foreground">· agent</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{relativeTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ⌘/Ctrl + Enter submits the composer or reply. */
|
||||
function isSubmitShortcut(event: React.KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
|
||||
}
|
||||
|
||||
function resolveAuthor(
|
||||
comment: DocumentAnnotationComment,
|
||||
maps: {
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): { name: string; role: "board" | "agent" } {
|
||||
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; imageUrl?: string | null } {
|
||||
if (comment.authorAgentId) {
|
||||
const agent = maps.agentMap?.get(comment.authorAgentId);
|
||||
return {
|
||||
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
|
||||
role: "agent",
|
||||
agentIcon: agent?.icon,
|
||||
};
|
||||
}
|
||||
if (comment.authorUserId) {
|
||||
|
|
@ -552,11 +677,93 @@ function resolveAuthor(
|
|||
return {
|
||||
name: profile?.label ?? comment.authorUserId.slice(0, 8),
|
||||
role: "board",
|
||||
imageUrl: profile?.image ?? null,
|
||||
};
|
||||
}
|
||||
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
|
||||
}
|
||||
|
||||
interface OptimisticAuthor {
|
||||
id: string | null;
|
||||
name: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
function optimisticId(prefix: string): string {
|
||||
const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
||||
return `${prefix}-${random}`;
|
||||
}
|
||||
|
||||
function buildOptimisticComment(input: {
|
||||
body: string;
|
||||
threadId: string;
|
||||
target: DocumentAnnotationTarget;
|
||||
author: OptimisticAuthor;
|
||||
}): DocumentAnnotationComment {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: optimisticId("optimistic-comment"),
|
||||
companyId: "",
|
||||
threadId: input.threadId,
|
||||
issueId: input.target.kind === "issue" ? input.target.issueId : null,
|
||||
routineId: input.target.kind === "routine" ? input.target.routineId : null,
|
||||
documentId: "",
|
||||
body: input.body,
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: input.author.id,
|
||||
createdByRunId: null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptimisticThread(input: {
|
||||
body: string;
|
||||
selectedText: string;
|
||||
target: DocumentAnnotationTarget;
|
||||
documentKey: string;
|
||||
baseRevisionId: string;
|
||||
baseRevisionNumber: number;
|
||||
normalizedStart: number;
|
||||
markdownStart: number;
|
||||
author: OptimisticAuthor;
|
||||
}): DocumentAnnotationThreadWithComments {
|
||||
const id = optimisticId("optimistic-thread");
|
||||
const now = new Date();
|
||||
const comment = buildOptimisticComment({
|
||||
body: input.body,
|
||||
threadId: id,
|
||||
target: input.target,
|
||||
author: input.author,
|
||||
});
|
||||
// Only the fields the panel + overlay read need to be accurate; the optimistic
|
||||
// thread is swapped for the server copy on success. Cast through unknown so we
|
||||
// don't have to fabricate every backend-only column.
|
||||
return {
|
||||
id,
|
||||
issueId: input.target.kind === "issue" ? input.target.issueId : null,
|
||||
routineId: input.target.kind === "routine" ? input.target.routineId : null,
|
||||
documentKey: input.documentKey,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
selectedText: input.selectedText,
|
||||
normalizedStart: input.normalizedStart,
|
||||
markdownStart: input.markdownStart,
|
||||
originalRevisionId: input.baseRevisionId,
|
||||
originalRevisionNumber: input.baseRevisionNumber,
|
||||
currentRevisionId: input.baseRevisionId,
|
||||
currentRevisionNumber: input.baseRevisionNumber,
|
||||
createdByUserId: input.author.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
comments: [comment],
|
||||
} as unknown as DocumentAnnotationThreadWithComments;
|
||||
}
|
||||
|
||||
function truncate(value: string, limit: number) {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
|
|
|
|||
|
|
@ -517,20 +517,6 @@ export function FileViewerSheet({
|
|||
const copyFeedbackTimerRef = useRef<number | null>(null);
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
setElapsedMs(0);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
setElapsedMs(0);
|
||||
const interval = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - now);
|
||||
}, 75);
|
||||
return () => window.clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state?.path, state?.workspace, state?.projectId, state?.workspaceId]);
|
||||
|
||||
const resolveQuery = useQuery({
|
||||
queryKey: state
|
||||
? queryKeys.issues.fileResource(issueId, state)
|
||||
|
|
@ -554,6 +540,30 @@ export function FileViewerSheet({
|
|||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// `elapsedMs` only drives the progressive loading skeleton (see LoadingView).
|
||||
// Run the 75ms ticker *only* while a preview is still loading — leaving it
|
||||
// running after content arrives would re-render the whole sheet ~13x/second,
|
||||
// which forces the markdown body to re-render and discards scroll position
|
||||
// and text selection, producing visible flashing (PAP-10767).
|
||||
const isLoadingPreview =
|
||||
(resolveQuery.isFetching && !resolveQuery.data) ||
|
||||
(canPreview && contentQuery.isFetching && !contentQuery.data);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
setElapsedMs(0);
|
||||
return;
|
||||
}
|
||||
if (!isLoadingPreview) return;
|
||||
const now = Date.now();
|
||||
setElapsedMs(0);
|
||||
const interval = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - now);
|
||||
}, 75);
|
||||
return () => window.clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state?.path, state?.workspace, state?.projectId, state?.workspaceId, isLoadingPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resolveQuery.isError) {
|
||||
const normalized = normalizeError(resolveQuery.error);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
VIRTUALIZED_THREAD_ROW_THRESHOLD,
|
||||
canStopIssueChatRun,
|
||||
findLatestCommentMessageIndex,
|
||||
getVirtualizedMeasurementScrollAdjustment,
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
|
|
@ -674,6 +675,51 @@ describe("IssueChatThread", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("cycles work modes and prevents default when iOS leaves the keydown code empty", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueWorkMode="standard"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard");
|
||||
|
||||
// iOS Safari with a hardware keyboard frequently reports an empty `code` for
|
||||
// cmd-period. Without a `key` fallback the handler returns early, never calls
|
||||
// preventDefault, and Safari's default cancel/dismiss closes the view.
|
||||
const evt = new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "",
|
||||
key: ".",
|
||||
metaKey: true,
|
||||
});
|
||||
act(() => {
|
||||
composer?.dispatchEvent(evt);
|
||||
});
|
||||
|
||||
expect(evt.defaultPrevented).toBe(true);
|
||||
expect(composer?.getAttribute("data-pending-work-mode")).not.toBe("standard");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("virtualizes long merged threads so only a windowed slice mounts", () => {
|
||||
const root = createRoot(container);
|
||||
const totalMergedRows =
|
||||
|
|
@ -1343,6 +1389,31 @@ describe("IssueChatThread", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the viewport anchored when virtualized rows above it remeasure", () => {
|
||||
expect(getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: 200,
|
||||
previousSize: 220,
|
||||
nextSize: 360,
|
||||
viewportStart: 480,
|
||||
})).toBe(140);
|
||||
|
||||
expect(getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: 200,
|
||||
previousSize: 360,
|
||||
nextSize: 180,
|
||||
viewportStart: 620,
|
||||
})).toBe(-180);
|
||||
});
|
||||
|
||||
it("does not scroll-anchor virtualized measurement changes inside the viewport", () => {
|
||||
expect(getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: 420,
|
||||
previousSize: 220,
|
||||
nextSize: 360,
|
||||
viewportStart: 480,
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
it("renders virtualized rows with the same role/kind metadata as the direct path", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import { copyTextToClipboard } from "../lib/clipboard";
|
|||
import {
|
||||
buildIssueChatMessages,
|
||||
formatDurationWords,
|
||||
isCoTSegmentActive,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
|
|
@ -870,13 +871,16 @@ function IssueChatChainOfThought({
|
|||
(p): p is ToolCallMessagePart => p.type === "tool-call",
|
||||
);
|
||||
|
||||
const isActive = isMessageRunning;
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
|
||||
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
|
||||
? (custom.chainOfThoughtSegments as SegmentTiming[])
|
||||
: [];
|
||||
const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null;
|
||||
const isActive = isCoTSegmentActive({
|
||||
isMessageRunning,
|
||||
segmentIndex: myIndex,
|
||||
segmentCount: rawSegments.length,
|
||||
});
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -2941,6 +2945,18 @@ type SimpleVirtualItem = {
|
|||
size: number;
|
||||
};
|
||||
|
||||
export function getVirtualizedMeasurementScrollAdjustment(args: {
|
||||
itemStart: number;
|
||||
previousSize: number;
|
||||
nextSize: number;
|
||||
viewportStart: number;
|
||||
}) {
|
||||
const { itemStart, previousSize, nextSize, viewportStart } = args;
|
||||
const previousEnd = itemStart + previousSize;
|
||||
if (previousEnd > viewportStart) return 0;
|
||||
return nextSize - previousSize;
|
||||
}
|
||||
|
||||
function useIssueThreadVirtualizer({
|
||||
count,
|
||||
estimateSize,
|
||||
|
|
@ -3055,7 +3071,20 @@ function useIssueThreadVirtualizer({
|
|||
const key = getItemKey(index);
|
||||
const previousSize = measuredSizeByKeyRef.current.get(key) ?? estimatedSize;
|
||||
if (Math.abs(previousSize - measuredSize) < 1) return;
|
||||
const scrollAdjustment = getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: itemStarts[index] ?? scrollMargin,
|
||||
previousSize,
|
||||
nextSize: measuredSize,
|
||||
viewportStart: Math.max(scrollMargin, scrollOffset()),
|
||||
});
|
||||
measuredSizeByKeyRef.current.set(key, measuredSize);
|
||||
if (Math.abs(scrollAdjustment) >= 1) {
|
||||
if (mode.kind === "window") {
|
||||
window.scrollBy({ top: scrollAdjustment, behavior: "auto" });
|
||||
} else {
|
||||
mode.element.scrollBy({ top: scrollAdjustment, behavior: "auto" });
|
||||
}
|
||||
}
|
||||
rerender((value) => value + 1);
|
||||
},
|
||||
};
|
||||
|
|
@ -3746,7 +3775,13 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
|
||||
|
||||
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
|
||||
// Match the period via both `code` and `key`: iOS Safari with a hardware
|
||||
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
|
||||
// lets the event fall through and triggers Safari's default cancel/dismiss
|
||||
// (which closes the view). Catching `key === "."` keeps the shortcut working
|
||||
// on iOS while preserving desktop behavior.
|
||||
const isPeriod = evt.code === "Period" || evt.key === ".";
|
||||
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
|
||||
evt.preventDefault();
|
||||
setPendingWorkMode((current) => nextWorkMode(current, true));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import { copyTextToClipboard } from "../lib/clipboard";
|
|||
import {
|
||||
buildIssueChatMessages,
|
||||
formatDurationWords,
|
||||
isCoTSegmentActive,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
|
|
@ -849,13 +850,16 @@ function IssueChatChainOfThought({
|
|||
(p): p is ToolCallMessagePart => p.type === "tool-call",
|
||||
);
|
||||
|
||||
const isActive = isMessageRunning;
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
|
||||
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
|
||||
? (custom.chainOfThoughtSegments as SegmentTiming[])
|
||||
: [];
|
||||
const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null;
|
||||
const isActive = isCoTSegmentActive({
|
||||
isMessageRunning,
|
||||
segmentIndex: myIndex,
|
||||
segmentCount: rawSegments.length,
|
||||
});
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -3587,7 +3591,13 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
|
||||
|
||||
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
|
||||
// Match the period via both `code` and `key`: iOS Safari with a hardware
|
||||
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
|
||||
// lets the event fall through and triggers Safari's default cancel/dismiss
|
||||
// (which closes the view). Catching `key === "."` keeps the shortcut working
|
||||
// on iOS while preserving desktop behavior.
|
||||
const isPeriod = evt.code === "Period" || evt.key === ".";
|
||||
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
|
||||
evt.preventDefault();
|
||||
setPendingWorkMode((current) => nextWorkMode(current, false));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,31 @@ import {
|
|||
IssueDocumentAnnotations,
|
||||
} from "./IssueDocumentAnnotations";
|
||||
|
||||
const mockAnnotationsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
}));
|
||||
const mockAnnotationsApi = vi.hoisted(() => {
|
||||
const api = {
|
||||
list: vi.fn(),
|
||||
listForTarget: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getForTarget: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createForTarget: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
addCommentForTarget: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
updateStatusForTarget: vi.fn(),
|
||||
};
|
||||
api.listForTarget.mockImplementation((target, options) =>
|
||||
target.kind === "issue" ? api.list(target.issueId, target.documentKey, options) : api.list(target.routineId, target.documentKey, options));
|
||||
api.getForTarget.mockImplementation((target, threadId) =>
|
||||
target.kind === "issue" ? api.get(target.issueId, target.documentKey, threadId) : api.get(target.routineId, target.documentKey, threadId));
|
||||
api.createForTarget.mockImplementation((target, data) =>
|
||||
target.kind === "issue" ? api.create(target.issueId, target.documentKey, data) : api.create(target.routineId, target.documentKey, data));
|
||||
api.addCommentForTarget.mockImplementation((target, threadId, data) =>
|
||||
target.kind === "issue" ? api.addComment(target.issueId, target.documentKey, threadId, data) : api.addComment(target.routineId, target.documentKey, threadId, data));
|
||||
api.updateStatusForTarget.mockImplementation((target, threadId, status) =>
|
||||
target.kind === "issue" ? api.updateStatus(target.issueId, target.documentKey, threadId, status) : api.updateStatus(target.routineId, target.documentKey, threadId, status));
|
||||
return api;
|
||||
});
|
||||
|
||||
const mockPendingAnchor = vi.hoisted(() => ({
|
||||
selector: {
|
||||
|
|
@ -110,6 +128,12 @@ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
|||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function dispatchSubmitShortcut(textarea: HTMLTextAreaElement) {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -342,6 +366,61 @@ describe("IssueDocumentAnnotations", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("offsets the desktop annotation panel from the document with a left margin when there is room", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
if (this instanceof HTMLElement && this.id === "main-content") {
|
||||
return rectFor(0, 0, 1400, 800);
|
||||
}
|
||||
if (
|
||||
this instanceof HTMLElement
|
||||
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
|
||||
) {
|
||||
return rectFor(80, 120, 640, 620);
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<main id="main-content">
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</main>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
|
||||
expect(anchor).not.toBeNull();
|
||||
// The document body ends at 640; the panel should clear it with a margin
|
||||
// rather than sitting flush against the document's right edge.
|
||||
expect(parseFloat(anchor!.style.left)).toBeGreaterThan(640);
|
||||
expect(anchor!.style.left).toBe("664px");
|
||||
} finally {
|
||||
rectSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-opens the panel and focuses the thread when deep-linked", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "thread-99" })]);
|
||||
const root = createRoot(container);
|
||||
|
|
@ -387,10 +466,11 @@ describe("IssueDocumentAnnotations", () => {
|
|||
expect(reason!.textContent).toMatch(/draft/i);
|
||||
});
|
||||
|
||||
it("filters resolved threads behind their tab", async () => {
|
||||
it("shows open and resolved threads together in a single list (no filter tabs)", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||
makeThread({ id: "orphan-1", anchorState: "orphaned" }),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
|
|
@ -406,19 +486,43 @@ describe("IssueDocumentAnnotations", () => {
|
|||
await flush();
|
||||
await flush();
|
||||
|
||||
// Open filter shows only open
|
||||
// Open + resolved both render without any filter interaction.
|
||||
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||
// Orphaned threads can't be anchored in the doc, so they stay hidden.
|
||||
expect(container.querySelector('[data-thread-id="orphan-1"]')).toBeNull();
|
||||
|
||||
// Switch to Resolved
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
// The Open/Resolved/Stale/Orphaned filter chips are gone.
|
||||
const filterChip = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
["Open", "Resolved", "Stale", "Orphaned"].includes((button.textContent ?? "").trim()),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
expect(filterChip).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders threads by document position, not API/recency order", async () => {
|
||||
// Returned out of document order: later-in-doc first, earlier-in-doc last.
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "thread-late", normalizedStart: 900, markdownStart: 900 }),
|
||||
makeThread({ id: "thread-early", normalizedStart: 10, markdownStart: 10 }),
|
||||
makeThread({ id: "thread-mid", normalizedStart: 400, markdownStart: 400 }),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||
const order = Array.from(container.querySelectorAll("[data-thread-id]"))
|
||||
.map((el) => el.getAttribute("data-thread-id"));
|
||||
expect(order).toEqual(["thread-early", "thread-mid", "thread-late"]);
|
||||
});
|
||||
|
||||
it("renders author name + role from agent and user maps", async () => {
|
||||
|
|
@ -500,11 +604,15 @@ describe("IssueDocumentAnnotations", () => {
|
|||
await act(async () => threadCard!.click());
|
||||
await flush();
|
||||
|
||||
const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? "";
|
||||
const expandedThread = container.querySelector('[data-thread-id="open-1"]');
|
||||
const expandedText = expandedThread?.textContent ?? "";
|
||||
expect(expandedText).toContain("Dotta");
|
||||
expect(expandedText).not.toContain("· board");
|
||||
expect(expandedText).toContain("UXDesigner");
|
||||
expect(expandedText).toContain("· agent");
|
||||
// Each rendered comment shows an author avatar.
|
||||
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"]') ?? [];
|
||||
expect(avatars.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not render a persistent New comment on selection hint when panel is open", async () => {
|
||||
|
|
@ -624,6 +732,173 @@ describe("IssueDocumentAnnotations", () => {
|
|||
expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("keeps the composer visible with the draft when creating a thread fails", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
mockAnnotationsApi.create.mockRejectedValue(new Error("Annotation anchor does not match the current document revision"));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
||||
expect(selectButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
selectButton!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
await act(async () => {
|
||||
setTextareaValue(composer!, "New anchored comment");
|
||||
});
|
||||
await flush();
|
||||
|
||||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Comment",
|
||||
);
|
||||
expect(submit).not.toBeUndefined();
|
||||
await act(async () => {
|
||||
submit!.click();
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const composerAfterFailure = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composerAfterFailure).not.toBeNull();
|
||||
expect(composerAfterFailure!.value).toBe("New anchored comment");
|
||||
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
||||
.toContain("Annotation anchor does not match the current document revision");
|
||||
});
|
||||
|
||||
it("submits a new anchored comment with ⌘↵", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" }));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
||||
await act(async () => selectButton!.click());
|
||||
await flush();
|
||||
|
||||
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
await act(async () => setTextareaValue(composer!, "Submitted via shortcut"));
|
||||
await flush();
|
||||
await act(async () => dispatchSubmitShortcut(composer!));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", {
|
||||
baseRevisionId: "rev-4",
|
||||
baseRevisionNumber: 4,
|
||||
selector: mockPendingAnchor.selector,
|
||||
body: "Submitted via shortcut",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a reply with ⌘↵", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
|
||||
mockAnnotationsApi.addComment.mockResolvedValue(makeThread({ id: "open-1" }).comments[0]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||
await act(async () => openThread!.click());
|
||||
await flush();
|
||||
|
||||
const reply = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(reply).not.toBeNull();
|
||||
await act(async () => setTextareaValue(reply!, "Replying via shortcut"));
|
||||
await flush();
|
||||
await act(async () => dispatchSubmitShortcut(reply!));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(mockAnnotationsApi.addComment).toHaveBeenCalledWith("issue-1", "plan", "open-1", {
|
||||
body: "Replying via shortcut",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a reply draft visible when submitting the reply fails", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "open-1" })]);
|
||||
mockAnnotationsApi.addComment.mockRejectedValue(new Error("Failed to add reply"));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||
expect(openThread).not.toBeNull();
|
||||
await act(async () => openThread!.click());
|
||||
await flush();
|
||||
|
||||
const reply = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(reply).not.toBeNull();
|
||||
await act(async () => setTextareaValue(reply!, "Reply should stay visible"));
|
||||
await flush();
|
||||
|
||||
const replyButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Reply",
|
||||
);
|
||||
expect(replyButton).not.toBeUndefined();
|
||||
await act(async () => replyButton!.click());
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const replyAfterFailure = container.querySelector(
|
||||
'[data-testid="document-annotation-reply-open-1"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(replyAfterFailure).not.toBeNull();
|
||||
expect(replyAfterFailure!.value).toBe("Reply should stay visible");
|
||||
expect(container.querySelector('[data-testid="document-annotation-error"]')?.textContent)
|
||||
.toContain("Failed to add reply");
|
||||
});
|
||||
|
||||
it("shows resolve and reopen actions and updates thread status", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
|
|
@ -657,13 +932,7 @@ describe("IssueDocumentAnnotations", () => {
|
|||
await flush();
|
||||
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "open-1", "resolved");
|
||||
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
await flush();
|
||||
|
||||
// Resolved threads stay in the same list (filter tabs were removed).
|
||||
const resolvedThread = container.querySelector('[data-thread-id="resolved-1"]') as HTMLElement | null;
|
||||
expect(resolvedThread).not.toBeNull();
|
||||
await act(async () => resolvedThread!.click());
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from
|
|||
import { MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
|
|
@ -13,12 +13,15 @@ import type { CompanyUserProfile } from "@/lib/company-members";
|
|||
|
||||
const DESKTOP_ANNOTATION_PANEL_WIDTH = 360;
|
||||
const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
|
||||
const DESKTOP_ANNOTATION_PANEL_GAP = 12;
|
||||
const DESKTOP_ANNOTATION_PANEL_GAP = 24;
|
||||
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
|
||||
|
||||
type AnnotationDocument = Pick<IssueDocument, "key" | "latestRevisionId" | "latestRevisionNumber">;
|
||||
|
||||
export interface IssueDocumentAnnotationsProps {
|
||||
issueId: string;
|
||||
doc: IssueDocument;
|
||||
doc: AnnotationDocument;
|
||||
target?: DocumentAnnotationTarget;
|
||||
/** The body that is being rendered/edited (current or historical revision). */
|
||||
bodyMarkdown: string;
|
||||
/** True when a draft has unsaved changes or is currently saving. */
|
||||
|
|
@ -34,7 +37,7 @@ export interface IssueDocumentAnnotationsProps {
|
|||
/** Controlled panel state. Caller owns this so the count chip can live in the doc header. */
|
||||
panelOpen: boolean;
|
||||
onPanelOpenChange: (open: boolean) => void;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
|
||||
defaultFocusedThreadId?: string;
|
||||
|
|
@ -43,6 +46,7 @@ export interface IssueDocumentAnnotationsProps {
|
|||
export function IssueDocumentAnnotations({
|
||||
issueId,
|
||||
doc,
|
||||
target,
|
||||
bodyMarkdown,
|
||||
draftDirty,
|
||||
draftConflicted,
|
||||
|
|
@ -105,7 +109,12 @@ export function IssueDocumentAnnotations({
|
|||
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
);
|
||||
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
|
||||
const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top);
|
||||
// Clamp the panel below the sticky top nav (the scroll container's top edge)
|
||||
// so the comments thread never tucks under the nav bar while scrolling.
|
||||
const boundaryTop = boundaryRect?.top ?? DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const minTop = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, boundaryTop)
|
||||
+ DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const top = Math.max(minTop, rect.top);
|
||||
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
|
||||
const spaceRightOfDocument = boundaryRight
|
||||
- desiredLeft
|
||||
|
|
@ -147,8 +156,12 @@ export function IssueDocumentAnnotations({
|
|||
}, [doc.key, isMobile, panelOpen]);
|
||||
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
|
||||
queryKey: target?.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
|
||||
queryFn: () => target
|
||||
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
|
||||
: documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const allThreads = annotationsQuery.data ?? [];
|
||||
|
|
@ -242,6 +255,30 @@ export function IssueDocumentAnnotations({
|
|||
[allThreads],
|
||||
);
|
||||
|
||||
const fallbackDesktopPanelFrame = useMemo(() => {
|
||||
if (!panelOpen || isMobile || desktopPanelFrame || typeof window === "undefined") return null;
|
||||
const width = Math.min(
|
||||
DESKTOP_ANNOTATION_PANEL_WIDTH,
|
||||
Math.max(
|
||||
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
|
||||
window.innerWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
),
|
||||
);
|
||||
return {
|
||||
left: Math.max(
|
||||
DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
window.innerWidth - width - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
),
|
||||
top: DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
maxHeight: Math.max(
|
||||
240,
|
||||
window.innerHeight - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
),
|
||||
width,
|
||||
};
|
||||
}, [desktopPanelFrame, isMobile, panelOpen]);
|
||||
const renderedDesktopPanelFrame = desktopPanelFrame ?? fallbackDesktopPanelFrame;
|
||||
|
||||
const annotationPanel = panelOpen ? (
|
||||
<DocumentAnnotationPanel
|
||||
open={panelOpen}
|
||||
|
|
@ -255,6 +292,7 @@ export function IssueDocumentAnnotations({
|
|||
}
|
||||
}}
|
||||
issueId={issueId}
|
||||
target={target}
|
||||
documentKey={doc.key}
|
||||
documentRevisionNumber={doc.latestRevisionNumber}
|
||||
baseRevisionId={doc.latestRevisionId}
|
||||
|
|
@ -272,7 +310,7 @@ export function IssueDocumentAnnotations({
|
|||
newCommentDisabled={newCommentDisabled}
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
isMobile={isMobile}
|
||||
desktopWidth={desktopPanelFrame?.width}
|
||||
desktopWidth={renderedDesktopPanelFrame?.width}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
|
|
@ -304,18 +342,19 @@ export function IssueDocumentAnnotations({
|
|||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
hideResolved
|
||||
captureSelectionRequestId={captureSelectionRequestId}
|
||||
pendingHighlightText={composerAnchor?.selectedText ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
{panelOpen && !isMobile && desktopPanelFrame ? (
|
||||
{panelOpen && !isMobile && renderedDesktopPanelFrame ? (
|
||||
<div
|
||||
data-testid="document-annotation-panel-anchor"
|
||||
className="pointer-events-auto fixed z-[60] hidden lg:block"
|
||||
style={{
|
||||
left: desktopPanelFrame.left,
|
||||
maxHeight: desktopPanelFrame.maxHeight,
|
||||
top: desktopPanelFrame.top,
|
||||
width: desktopPanelFrame.width,
|
||||
left: renderedDesktopPanelFrame.left,
|
||||
maxHeight: renderedDesktopPanelFrame.maxHeight,
|
||||
top: renderedDesktopPanelFrame.top,
|
||||
width: renderedDesktopPanelFrame.width,
|
||||
}}
|
||||
>
|
||||
{annotationPanel}
|
||||
|
|
@ -329,6 +368,7 @@ export function IssueDocumentAnnotations({
|
|||
export interface DocumentAnnotationsCountChipProps {
|
||||
issueId: string;
|
||||
docKey: string;
|
||||
target?: DocumentAnnotationTarget;
|
||||
panelOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
|
@ -340,12 +380,17 @@ export interface DocumentAnnotationsCountChipProps {
|
|||
export function DocumentAnnotationsCountChip({
|
||||
issueId,
|
||||
docKey,
|
||||
target,
|
||||
panelOpen,
|
||||
onToggle,
|
||||
}: DocumentAnnotationsCountChipProps) {
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
queryKey: target?.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => target
|
||||
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
|
||||
: documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const threads = annotationsQuery.data ?? [];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ const mockProjectsApi = vi.hoisted(() => ({
|
|||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
controlRuntimeCommands: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listLabels: vi.fn(),
|
||||
|
|
@ -56,6 +60,10 @@ vi.mock("../api/projects", () => ({
|
|||
projectsApi: mockProjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/execution-workspaces", () => ({
|
||||
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
|
@ -389,6 +397,7 @@ describe("IssueProperties", () => {
|
|||
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockIssuesApi.createLabel.mockResolvedValue(createLabel({
|
||||
|
|
@ -637,6 +646,11 @@ describe("IssueProperties", () => {
|
|||
expect(blockerLink).not.toBeNull();
|
||||
expect(blockerLink?.textContent).toContain("PAP-2");
|
||||
expect(blockerLink?.closest("button")).toBeNull();
|
||||
expect(blockerLink?.className).toContain("px-2");
|
||||
expect(blockerLink?.className).toContain("py-0.5");
|
||||
expect(blockerLink?.className).toContain("text-xs");
|
||||
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
|
||||
expect(removeButton?.className).toContain("absolute");
|
||||
expect(container.textContent).toContain("Add blocker");
|
||||
expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull();
|
||||
|
||||
|
|
@ -917,6 +931,14 @@ describe("IssueProperties", () => {
|
|||
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
const serviceUrl = "http://127.0.0.1:62475";
|
||||
const updatedWorkspace = createExecutionWorkspace({
|
||||
mode: "isolated_workspace",
|
||||
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "stopped", stoppedAt: new Date("2026-04-06T12:06:00.000Z") })],
|
||||
});
|
||||
mockExecutionWorkspacesApi.controlRuntimeCommands.mockResolvedValue({
|
||||
workspace: updatedWorkspace,
|
||||
operation: {},
|
||||
});
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
projectId: "project-1",
|
||||
|
|
@ -924,7 +946,17 @@ describe("IssueProperties", () => {
|
|||
executionWorkspaceId: "workspace-1",
|
||||
currentExecutionWorkspace: createExecutionWorkspace({
|
||||
mode: "isolated_workspace",
|
||||
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
|
||||
config: {
|
||||
environmentId: null,
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
desiredState: null,
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running", configIndex: 0 })],
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
|
|
@ -934,11 +966,27 @@ describe("IssueProperties", () => {
|
|||
|
||||
const serviceLink = container.querySelector(`a[href="${serviceUrl}"]`);
|
||||
expect(serviceLink).not.toBeNull();
|
||||
expect(serviceLink?.getAttribute("target")).toBe("_blank");
|
||||
expect(serviceLink?.className).toContain("text-emerald");
|
||||
expect((container.textContent ?? "").indexOf("Service")).toBeLessThan(
|
||||
(container.textContent ?? "").indexOf("Workspace"),
|
||||
expect(serviceLink?.className).toContain("sm:self-start");
|
||||
expect(serviceLink?.className).not.toContain("sm:self-end");
|
||||
expect((container.textContent ?? "").indexOf("Workspace")).toBeLessThan(
|
||||
(container.textContent ?? "").indexOf("Service"),
|
||||
);
|
||||
const stopButton = container.querySelector<HTMLButtonElement>('button[aria-label="Stop"]');
|
||||
expect(stopButton).not.toBeUndefined();
|
||||
expect(stopButton?.getAttribute("data-size")).toBe("icon-xs");
|
||||
expect(stopButton?.getAttribute("data-variant")).toBe("outline");
|
||||
|
||||
await act(async () => {
|
||||
stopButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockExecutionWorkspacesApi.controlRuntimeCommands).toHaveBeenCalledWith(
|
||||
"workspace-1",
|
||||
"stop",
|
||||
expect.objectContaining({ action: "stop", runtimeServiceId: "service-1" }),
|
||||
);
|
||||
expect(container.textContent).toContain("Workspace service stopped.");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
|
@ -989,6 +1037,41 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("copies branch and folder values with visible feedback", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
executionWorkspaceId: "workspace-1",
|
||||
currentExecutionWorkspace: createExecutionWorkspace({
|
||||
branchName: "pap-1-workspace",
|
||||
cwd: "/tmp/paperclip/PAP-1",
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
const branchCopyButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Copy pap-1-workspace to clipboard"]',
|
||||
);
|
||||
expect(branchCopyButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
branchCopyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("pap-1-workspace");
|
||||
expect(container.textContent).toContain("Copied");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not show a service link for the main shared workspace", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
const serviceUrl = "http://127.0.0.1:62475";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue, IssueLabel, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
import type { Issue, IssueLabel, Project } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AdapterModel } from "../api/agents";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
|
|
@ -72,6 +73,11 @@ import {
|
|||
type HandoffChipResolvers,
|
||||
} from "./interrupt-handoff/InterruptHandoffViews";
|
||||
import { describeReassignInterrupt } from "../lib/interrupt-handoff";
|
||||
import {
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
WorkspaceRuntimeQuickControls,
|
||||
type WorkspaceRuntimeControlRequest,
|
||||
} from "./WorkspaceRuntimeControls";
|
||||
|
||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -93,11 +99,17 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.C
|
|||
type="button"
|
||||
className="text-sm font-mono min-w-0 break-all text-left cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied!" : "Click to copy"}
|
||||
title={copied ? "Copied" : "Copy to clipboard"}
|
||||
aria-label={`Copy ${value} to clipboard`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
{copied && <Check className="h-3 w-3 text-green-500 shrink-0 mt-0.5" />}
|
||||
{copied && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-300" role="status">
|
||||
<Check className="h-3 w-3 shrink-0" />
|
||||
Copied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -143,12 +155,6 @@ function isMainIssueWorkspace(input: {
|
|||
return linkedProjectWorkspaceId === primaryWorkspaceId;
|
||||
}
|
||||
|
||||
function runningRuntimeServiceWithUrl(
|
||||
runtimeServices: WorkspaceRuntimeService[] | null | undefined,
|
||||
) {
|
||||
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value: string | null | undefined) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
|
|
@ -419,6 +425,11 @@ function RemovableIssueReferencePill({
|
|||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const issueLabel = issue.identifier ?? issue.title;
|
||||
const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
|
||||
const chipClassName = cn(
|
||||
"paperclip-mention-chip paperclip-mention-chip--issue",
|
||||
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
|
||||
issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
|
||||
|
|
@ -438,18 +449,10 @@ function RemovableIssueReferencePill({
|
|||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-mention-kind="issue"
|
||||
className={cn(
|
||||
"paperclip-mention-chip paperclip-mention-chip--issue group",
|
||||
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
|
||||
)}
|
||||
title={issue.title}
|
||||
aria-label={`Task ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
<span className="group relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
|
||||
className="absolute -right-1 -top-1 z-10 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
onClick={handleRemove}
|
||||
|
|
@ -459,13 +462,22 @@ function RemovableIssueReferencePill({
|
|||
{issue.identifier ? (
|
||||
<Link
|
||||
to={`/issues/${issueLabel}`}
|
||||
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
data-mention-kind="issue"
|
||||
className={chipClassName}
|
||||
title={issue.title}
|
||||
aria-label={`Task ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
|
||||
<span
|
||||
data-mention-kind="issue"
|
||||
className={chipClassName}
|
||||
title={issue.title}
|
||||
aria-label={`Task: ${issue.title}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
|
||||
|
|
@ -625,6 +637,8 @@ export function IssueProperties({
|
|||
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
|
||||
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
|
||||
const [watchdogOpen, setWatchdogOpen] = useState(false);
|
||||
const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? "");
|
||||
const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? "");
|
||||
|
|
@ -734,10 +748,54 @@ export function IssueProperties({
|
|||
[issue, issueProject],
|
||||
);
|
||||
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
|
||||
const liveWorkspaceService = useMemo(() => {
|
||||
if (issueUsesMainWorkspace) return null;
|
||||
return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices);
|
||||
}, [issue.currentExecutionWorkspace?.runtimeServices, issueUsesMainWorkspace]);
|
||||
const workspaceRuntimeConfig = issueUsesMainWorkspace
|
||||
? null
|
||||
: issue.currentExecutionWorkspace?.config?.workspaceRuntime ?? null;
|
||||
const workspaceRuntimeServices = issue.currentExecutionWorkspace?.runtimeServices ?? [];
|
||||
const workspaceCanRunCommands = Boolean(issue.currentExecutionWorkspace?.cwd);
|
||||
const workspaceCanStartServices = Boolean(workspaceRuntimeConfig) && workspaceCanRunCommands;
|
||||
const workspaceRuntimeSections = useMemo(() => buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: workspaceRuntimeConfig,
|
||||
runtimeServices: workspaceRuntimeServices,
|
||||
canStartServices: workspaceCanStartServices,
|
||||
canRunJobs: workspaceCanRunCommands,
|
||||
}), [workspaceCanRunCommands, workspaceCanStartServices, workspaceRuntimeConfig, workspaceRuntimeServices]);
|
||||
const hasWorkspaceRuntimeControls = !issueUsesMainWorkspace && (
|
||||
workspaceRuntimeSections.services.length > 0
|
||||
|| workspaceRuntimeSections.otherServices.length > 0
|
||||
);
|
||||
const controlWorkspaceRuntime = useMutation({
|
||||
mutationFn: (request: WorkspaceRuntimeControlRequest) => {
|
||||
const workspaceId = issue.currentExecutionWorkspace?.id ?? issue.executionWorkspaceId;
|
||||
if (!workspaceId) throw new Error("This task is not attached to a workspace.");
|
||||
return executionWorkspacesApi.controlRuntimeCommands(workspaceId, request.action, request);
|
||||
},
|
||||
onSuccess: (result, request) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
|
||||
if (companyId) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
|
||||
}
|
||||
setRuntimeActionErrorMessage(null);
|
||||
setRuntimeActionMessage(
|
||||
request.action === "run"
|
||||
? "Workspace job completed."
|
||||
: request.action === "stop"
|
||||
? "Workspace service stopped."
|
||||
: request.action === "restart"
|
||||
? "Workspace service restarted."
|
||||
: "Workspace service started.",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
setRuntimeActionMessage(null);
|
||||
setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands.");
|
||||
},
|
||||
});
|
||||
const pendingWorkspaceRuntimeAction = controlWorkspaceRuntime.isPending ? controlWorkspaceRuntime.variables ?? null : null;
|
||||
const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? [];
|
||||
const relatedTasks = useMemo(() => {
|
||||
const excluded = new Set<string>();
|
||||
|
|
@ -2590,34 +2648,43 @@ export function IssueProperties({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{liveWorkspaceService || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||
{hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
{liveWorkspaceService?.url && (
|
||||
<PropertyRow label="Service">
|
||||
<a
|
||||
href={liveWorkspaceService.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-start gap-1 text-sm font-mono text-emerald-700 hover:text-emerald-800 hover:underline dark:text-emerald-300 dark:hover:text-emerald-200"
|
||||
>
|
||||
<span className="min-w-0 break-all">{liveWorkspaceService.url}</span>
|
||||
<ExternalLink className="mt-1 h-3 w-3 shrink-0" />
|
||||
</a>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{showWorkspaceDetailLink && issue.executionWorkspaceId && (
|
||||
<PropertyRow label="Workspace">
|
||||
<Link
|
||||
to={`/execution-workspaces/${issue.executionWorkspaceId}`}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
className="text-sm text-primary hover:underline inline-flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<Hexagon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
View workspace
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{hasWorkspaceRuntimeControls && (
|
||||
<PropertyRow label="Service">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<WorkspaceRuntimeQuickControls
|
||||
sections={workspaceRuntimeSections}
|
||||
isPending={controlWorkspaceRuntime.isPending}
|
||||
pendingRequest={pendingWorkspaceRuntimeAction}
|
||||
onAction={(request) => controlWorkspaceRuntime.mutate(request)}
|
||||
square
|
||||
align="start"
|
||||
iconOnly
|
||||
/>
|
||||
{runtimeActionMessage ? (
|
||||
<span className="text-xs text-muted-foreground" role="status">{runtimeActionMessage}</span>
|
||||
) : null}
|
||||
{runtimeActionErrorMessage ? (
|
||||
<span className="text-xs text-destructive" role="alert">{runtimeActionErrorMessage}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.currentExecutionWorkspace?.branchName && (
|
||||
<PropertyRow label="Branch">
|
||||
<TruncatedCopyable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
...props
|
||||
}: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
flushSync(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
const SAMPLE = "Some text\n\n```ts\nconst answer = 42;\n```\n\nAnd a [link](https://example.com).";
|
||||
|
||||
function tree(children: string, queryClient: QueryClient) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("MarkdownBody re-render stability (PAP-10767)", () => {
|
||||
it("preserves rendered DOM nodes across a parent re-render with unchanged props", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
const preBefore = container.querySelector("pre");
|
||||
const codeBefore = container.querySelector("pre code");
|
||||
const anchorBefore = container.querySelector("a");
|
||||
expect(preBefore).not.toBeNull();
|
||||
expect(codeBefore).not.toBeNull();
|
||||
expect(anchorBefore).not.toBeNull();
|
||||
|
||||
// Re-render the identical tree. Before the memoization fix, MarkdownBody
|
||||
// rebuilt its react-markdown `components` map on every render, giving each
|
||||
// custom element (pre/code/a/...) a brand-new component *type* — which made
|
||||
// React unmount and remount the whole subtree, discarding scroll position
|
||||
// and text selection and producing the visible flashing in the file viewer.
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
const preAfter = container.querySelector("pre");
|
||||
const codeAfter = container.querySelector("pre code");
|
||||
const anchorAfter = container.querySelector("a");
|
||||
|
||||
// Same DOM node instances ⇒ React updated in place rather than remounting.
|
||||
expect(preAfter).toBe(preBefore);
|
||||
expect(codeAfter).toBe(codeBefore);
|
||||
expect(anchorAfter).toBe(anchorBefore);
|
||||
});
|
||||
|
||||
it("preserves text selection across a parent re-render with unchanged props", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
const paragraph = container.querySelector("p");
|
||||
const textNode = paragraph?.firstChild;
|
||||
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode!, 0);
|
||||
range.setEnd(textNode!, "Some text".length);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
expect(selection?.toString()).toBe("Some text");
|
||||
|
||||
flushSync(() => root?.render(tree(SAMPLE, queryClient)));
|
||||
|
||||
expect(window.getSelection()?.toString()).toBe("Some text");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { isValidElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
|
|
@ -644,7 +644,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
|||
);
|
||||
}
|
||||
|
||||
export function MarkdownBody({
|
||||
function MarkdownBodyImpl({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
|
|
@ -663,9 +663,13 @@ export function MarkdownBody({
|
|||
// may lack a CompanyProvider. A null context (or no companies yet) leaves
|
||||
// knownPrefixes undefined, which keeps issue auto-linking permissive.
|
||||
const company = useOptionalCompany();
|
||||
const knownPrefixes = company?.companies.length
|
||||
? company.companies.map((c) => c.issuePrefix)
|
||||
: undefined;
|
||||
const companies = company?.companies;
|
||||
// Stable identity so it can feed the memoized remark plugins without
|
||||
// re-creating them (and forcing a full markdown re-parse) every render.
|
||||
const knownPrefixes = useMemo(
|
||||
() => (companies?.length ? companies.map((c) => c.issuePrefix) : undefined),
|
||||
[companies],
|
||||
);
|
||||
const externalReferenceLookup = useMemo<MarkdownExternalReferenceMap | null>(() => {
|
||||
if (!externalReferences) return null;
|
||||
const lookup: MarkdownExternalReferenceMap = {};
|
||||
|
|
@ -675,20 +679,30 @@ export function MarkdownBody({
|
|||
}
|
||||
return lookup;
|
||||
}, [externalReferences]);
|
||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkWorkspaceFileRefs) {
|
||||
remarkPlugins.push(remarkWorkspaceFileRefs);
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
remarkPlugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
|
||||
}
|
||||
if (softBreaks) {
|
||||
remarkPlugins.push(remarkSoftBreaks);
|
||||
}
|
||||
const components: Components = {
|
||||
// react-markdown treats the values of `components` as component *types* and
|
||||
// the `remarkPlugins` array by identity. Rebuilding either on every render
|
||||
// forces react-markdown to unmount/remount the rendered tree, which discards
|
||||
// scroll position and text selection and causes visible flashing when a
|
||||
// parent re-renders frequently (see PAP-10767). Memoize both so re-renders
|
||||
// that don't change the inputs are cheap and non-destructive.
|
||||
const remarkPlugins = useMemo<NonNullable<Options["remarkPlugins"]>>(() => {
|
||||
const plugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
plugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkWorkspaceFileRefs) {
|
||||
plugins.push(remarkWorkspaceFileRefs);
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
plugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
|
||||
}
|
||||
if (softBreaks) {
|
||||
plugins.push(remarkSoftBreaks);
|
||||
}
|
||||
return plugins;
|
||||
}, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, linkWorkspaceFileRefs, linkIssueReferences, knownPrefixes, softBreaks]);
|
||||
const components = useMemo<Components>(() => {
|
||||
const map: Components = {
|
||||
p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => (
|
||||
<p {...paragraphProps} style={mergeWrapStyle(paragraphStyle as React.CSSProperties | undefined)}>
|
||||
{paragraphChildren}
|
||||
|
|
@ -828,22 +842,24 @@ export function MarkdownBody({
|
|||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
if (resolveImageSrc || onImageClick) {
|
||||
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||
const finalSrc = resolved ?? src;
|
||||
return (
|
||||
<img
|
||||
{...imgProps}
|
||||
src={finalSrc}
|
||||
alt={alt ?? ""}
|
||||
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
||||
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
if (resolveImageSrc || onImageClick) {
|
||||
map.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||
const finalSrc = resolved ?? src;
|
||||
return (
|
||||
<img
|
||||
{...imgProps}
|
||||
src={finalSrc}
|
||||
alt={alt ?? ""}
|
||||
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
||||
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [theme, linkIssueReferences, externalReferenceLookup, resolveImageSrc, onImageClick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -864,3 +880,5 @@ export function MarkdownBody({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MarkdownBody = memo(MarkdownBodyImpl);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type ReactNode } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
|
@ -202,7 +202,7 @@ describe("Sidebar", () => {
|
|||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -245,7 +245,7 @@ describe("Sidebar", () => {
|
|||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -283,7 +283,7 @@ describe("Sidebar", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows an Artifacts nav item directly below Goals", async () => {
|
||||
it("shows Skills directly below Artifacts in Work", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
|
|
@ -295,7 +295,15 @@ describe("Sidebar", () => {
|
|||
const navText = container.querySelector("nav")?.textContent ?? "";
|
||||
expect(navText).toContain("Goals");
|
||||
expect(navText).toContain("Artifacts");
|
||||
expect(navText).toContain("Skills");
|
||||
expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts"));
|
||||
expect(navText.indexOf("Artifacts")).toBeLessThan(navText.indexOf("Skills"));
|
||||
|
||||
const sections = [...container.querySelectorAll("nav > div")];
|
||||
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
|
||||
const companySection = sections.find((section) => section.textContent?.startsWith("Company"));
|
||||
expect(workSection?.textContent).toContain("Skills");
|
||||
expect(companySection?.textContent).not.toContain("Skills");
|
||||
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
|
|
@ -364,7 +372,7 @@ describe("Sidebar", () => {
|
|||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
flushSync(() => {
|
||||
toggle?.click();
|
||||
});
|
||||
expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -421,7 +429,7 @@ describe("Sidebar", () => {
|
|||
const pin = container.querySelector<HTMLButtonElement>('button[aria-label="Keep sidebar expanded"]');
|
||||
expect(pin).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
flushSync(() => {
|
||||
pin?.click();
|
||||
});
|
||||
expect(mockSidebar.setCollapsed).toHaveBeenCalledWith(false);
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export function Sidebar() {
|
|||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
|
||||
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
|
|
@ -205,7 +206,6 @@ export function Sidebar() {
|
|||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function WorkspaceFileLink({
|
|||
aria-label={ariaLabel}
|
||||
title={tooltip}
|
||||
className={cn(
|
||||
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-middle no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -14,6 +14,10 @@ import {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
|
||||
return {
|
||||
id: overrides.id ?? "service-1",
|
||||
|
|
|
|||
|
|
@ -226,12 +226,14 @@ function CommandActionButtons({
|
|||
pendingRequest,
|
||||
onAction,
|
||||
square,
|
||||
iconOnly,
|
||||
}: {
|
||||
item: WorkspaceRuntimeControlItem;
|
||||
isPending: boolean;
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
square?: boolean;
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
const actions: WorkspaceRuntimeAction[] =
|
||||
item.kind === "job"
|
||||
|
|
@ -241,7 +243,7 @@ function CommandActionButtons({
|
|||
: ["start"];
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
<div className={cn("flex gap-2", iconOnly ? "w-auto flex-row flex-wrap" : "w-full flex-col sm:w-auto sm:flex-row sm:flex-wrap")}>
|
||||
{actions.map((action) => {
|
||||
const request = buildRequest(item, action);
|
||||
const Icon = action === "stop" ? Square : action === "restart" ? RotateCcw : Play;
|
||||
|
|
@ -261,17 +263,19 @@ function CommandActionButtons({
|
|||
return (
|
||||
<Button
|
||||
key={`${item.key}:${action}`}
|
||||
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
||||
size="sm"
|
||||
variant={iconOnly ? "outline" : action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
||||
size={iconOnly ? "icon-xs" : "sm"}
|
||||
className={cn(
|
||||
"w-full justify-start sm:w-auto",
|
||||
iconOnly ? "shrink-0" : "w-full justify-start sm:w-auto",
|
||||
square ? "rounded-none" : null,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => onAction(request)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{showSpinner ? <Loader2 className="h-4 w-4 animate-spin" /> : <Icon className="h-4 w-4" />}
|
||||
{label}
|
||||
{iconOnly ? <span className="sr-only">{label}</span> : label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -289,6 +293,7 @@ function CommandSection({
|
|||
pendingRequest,
|
||||
onAction,
|
||||
square,
|
||||
iconOnly,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -299,6 +304,7 @@ function CommandSection({
|
|||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
square?: boolean;
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -330,6 +336,7 @@ function CommandSection({
|
|||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
iconOnly={iconOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
|
|
@ -466,24 +473,29 @@ export function WorkspaceRuntimeQuickControls({
|
|||
pendingRequest = null,
|
||||
onAction,
|
||||
square,
|
||||
align = "end",
|
||||
iconOnly = false,
|
||||
}: {
|
||||
sections: WorkspaceRuntimeControlSections;
|
||||
isPending?: boolean;
|
||||
pendingRequest?: WorkspaceRuntimeControlRequest | null;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
square?: boolean;
|
||||
align?: "start" | "end";
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
const controlItems = sections.services.length > 0 ? sections.services : sections.otherServices;
|
||||
const serviceUrl = getRunningRuntimeServiceUrl(sections);
|
||||
const alignEnd = align === "end";
|
||||
|
||||
if (controlItems.length === 0 && !serviceUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
<div className={cn("flex min-w-0 flex-col items-stretch gap-2", alignEnd ? "sm:items-end" : "sm:items-start")}>
|
||||
{controlItems.length > 0 ? (
|
||||
<div className="flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
<div className={cn("flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap", alignEnd ? "sm:justify-end" : "sm:justify-start")}>
|
||||
{controlItems.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 flex-col gap-1 sm:items-end">
|
||||
<div key={item.key} className={cn("flex min-w-0 flex-col gap-1", alignEnd ? "sm:items-end" : "sm:items-start")}>
|
||||
{controlItems.length > 1 ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.title}</span>
|
||||
) : null}
|
||||
|
|
@ -493,6 +505,7 @@ export function WorkspaceRuntimeQuickControls({
|
|||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
iconOnly={iconOnly}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -503,7 +516,10 @@ export function WorkspaceRuntimeQuickControls({
|
|||
href={serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline sm:self-end"
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline",
|
||||
alignEnd ? "sm:self-end" : "sm:self-start",
|
||||
)}
|
||||
>
|
||||
{serviceUrl}
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires";
|
|||
import { timeAgo } from "../../lib/timeAgo";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
import { InlineEntitySelector } from "../InlineEntitySelector";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations";
|
||||
import { AgentIcon } from "../AgentIconPicker";
|
||||
import { MarkdownEditor } from "../MarkdownEditor";
|
||||
import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor";
|
||||
|
|
@ -77,7 +78,11 @@ const signingModeDescriptions: Record<string, string> = {
|
|||
};
|
||||
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,
|
||||
|
|
@ -98,8 +103,11 @@ export function OverviewSection() {
|
|||
routineRuns,
|
||||
activity,
|
||||
saveRoutine,
|
||||
saveConflict,
|
||||
isSectionDirty,
|
||||
navigateToSection,
|
||||
} = ctx;
|
||||
const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen);
|
||||
|
||||
const activeTriggers = routine.triggers.length;
|
||||
const nextFire = useMemo(() => {
|
||||
|
|
@ -215,20 +223,63 @@ export function OverviewSection() {
|
|||
) : null}
|
||||
|
||||
{/* Instructions */}
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={editDraft.description}
|
||||
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
||||
placeholder="Add instructions..."
|
||||
bordered={false}
|
||||
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||
mentions={mentionOptions}
|
||||
onSubmit={() => {
|
||||
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||
saveRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-end">
|
||||
{routine.descriptionDocument ? (
|
||||
<DocumentAnnotationsCountChip
|
||||
issueId={routine.id}
|
||||
docKey="description"
|
||||
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
|
||||
panelOpen={descriptionAnnotationsOpen}
|
||||
onToggle={() => setDescriptionAnnotationsOpen((open) => !open)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{routine.descriptionDocument ? (
|
||||
<IssueDocumentAnnotations
|
||||
issueId={routine.id}
|
||||
doc={routine.descriptionDocument}
|
||||
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
|
||||
bodyMarkdown={editDraft.description}
|
||||
draftDirty={isSectionDirty("overview") || saveRoutine.isPending}
|
||||
draftConflicted={saveConflict}
|
||||
historicalPreview={false}
|
||||
locationHash={typeof window === "undefined" ? "" : window.location.hash}
|
||||
panelOpen={descriptionAnnotationsOpen}
|
||||
onPanelOpenChange={setDescriptionAnnotationsOpen}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={editDraft.description}
|
||||
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
||||
placeholder="Add instructions..."
|
||||
bordered={false}
|
||||
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||
mentions={mentionOptions}
|
||||
onSubmit={() => {
|
||||
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||
saveRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</IssueDocumentAnnotations>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={editDraft.description}
|
||||
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
||||
placeholder="Add instructions..."
|
||||
bordered={false}
|
||||
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||
mentions={mentionOptions}
|
||||
onSubmit={() => {
|
||||
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||
saveRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variables peek */}
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
|||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.documentRevisions("issue-1", "plan"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.documents("PAP-9403"),
|
||||
});
|
||||
|
|
@ -148,6 +151,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
|||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.documentRevisions("PAP-9403", "plan"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.documents("issue-1"),
|
||||
refetchType: "inactive",
|
||||
|
|
@ -186,6 +192,83 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
|||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-revisions", "issue-1"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "issue-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes document annotation caches when annotation activity arrives", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.document_annotation_comment_added",
|
||||
actorType: "user",
|
||||
actorId: "user-2",
|
||||
details: {
|
||||
identifier: "PAP-9403",
|
||||
documentKey: "plan",
|
||||
threadId: "thread-1",
|
||||
commentId: "comment-1",
|
||||
},
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.documents("issue-1"),
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes routine description annotation caches when routine annotation activity arrives", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "routine",
|
||||
entityId: "routine-1",
|
||||
action: "routine.document_annotation_comment_added",
|
||||
actorType: "user",
|
||||
actorId: "user-2",
|
||||
details: {
|
||||
documentKey: "description",
|
||||
threadId: "thread-1",
|
||||
commentId: "comment-1",
|
||||
},
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["routines"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["routines", "document-annotations", "routine-1", "description"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps self-authored comment events from refetching the active issue tree", () => {
|
||||
|
|
|
|||
|
|
@ -415,6 +415,20 @@ const ISSUE_DOCUMENT_ACTIVITY_ACTIONS = new Set([
|
|||
"issue.document_restored",
|
||||
"issue.document_deleted",
|
||||
]);
|
||||
const ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
|
||||
"issue.document_annotation_thread_created",
|
||||
"issue.document_annotation_comment_added",
|
||||
"issue.document_annotation_thread_resolved",
|
||||
"issue.document_annotation_thread_reopened",
|
||||
"issue.document_annotation_remapped",
|
||||
]);
|
||||
const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
|
||||
"routine.document_annotation_thread_created",
|
||||
"routine.document_annotation_comment_added",
|
||||
"routine.document_annotation_thread_resolved",
|
||||
"routine.document_annotation_thread_reopened",
|
||||
"routine.document_annotation_remapped",
|
||||
]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
||||
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
||||
|
||||
|
|
@ -710,6 +724,18 @@ function invalidateActivityQueries(
|
|||
queryClient.invalidateQueries({ queryKey: ["issues", "document-revisions", ref], ...invalidationOptions });
|
||||
}
|
||||
}
|
||||
if (
|
||||
action &&
|
||||
(ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action) || ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action))
|
||||
) {
|
||||
const documentKey = readString(details?.key) ?? readString(details?.documentKey);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: documentKey
|
||||
? ["issues", "document-annotations", ref, documentKey]
|
||||
: ["issues", "document-annotations", ref],
|
||||
...invalidationOptions,
|
||||
});
|
||||
}
|
||||
if (action?.startsWith("issue.thread_interaction_")) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), ...invalidationOptions });
|
||||
}
|
||||
|
|
@ -761,6 +787,12 @@ function invalidateActivityQueries(
|
|||
|
||||
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
|
||||
queryClient.invalidateQueries({ queryKey: ["routines"] });
|
||||
if (entityType === "routine" && action && ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action) && entityId) {
|
||||
const documentKey = readString(details?.key) ?? readString(details?.documentKey) ?? "description";
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["routines", "document-annotations", entityId, documentKey],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarProvider, useSidebar } from "./SidebarContext";
|
||||
|
|
@ -13,6 +13,10 @@ const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
|
|||
// Mutable media state driving the matchMedia mock.
|
||||
const mediaState = { mobile: false, hoverFine: true };
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
function setViewport({ mobile, hoverFine }: { mobile?: boolean; hoverFine?: boolean }) {
|
||||
if (typeof mobile === "boolean") mediaState.mobile = mobile;
|
||||
if (typeof hoverFine === "boolean") mediaState.hoverFine = hoverFine;
|
||||
|
|
@ -248,6 +252,42 @@ describe("SidebarContext", () => {
|
|||
act(() => capturedValue?.setPeeking(true));
|
||||
expect(capturedValue?.peeking).toBe(false);
|
||||
});
|
||||
|
||||
// iPadOS Safari keeps the hover/pointer media query false even with a
|
||||
// trackpad attached (PAP-10725); a real cursor still emits "mouse" pointer
|
||||
// events, which must unlock peek at runtime.
|
||||
it("peeks once a mouse-type pointer event is seen (iPad + trackpad)", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
|
||||
setViewport({ mobile: false, hoverFine: false });
|
||||
active = renderProvider();
|
||||
expect(capturedValue?.collapsed).toBe(true);
|
||||
|
||||
// No cursor seen yet → peek stays gated despite the media query.
|
||||
act(() => capturedValue?.setPeeking(true));
|
||||
expect(capturedValue?.peeking).toBe(false);
|
||||
|
||||
// A trackpad/mouse moves: a "mouse" pointer event unlocks peek.
|
||||
act(() => {
|
||||
const e = new Event("pointermove");
|
||||
(e as unknown as { pointerType: string }).pointerType = "mouse";
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
expect(capturedValue?.peeking).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores touch pointer events (touch-only stays gated)", () => {
|
||||
localStorage.setItem(COLLAPSED_STORAGE_KEY, "1");
|
||||
setViewport({ mobile: false, hoverFine: false });
|
||||
active = renderProvider();
|
||||
|
||||
act(() => capturedValue?.setPeeking(true));
|
||||
act(() => {
|
||||
const e = new Event("pointerover");
|
||||
(e as unknown as { pointerType: string }).pointerType = "touch";
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
expect(capturedValue?.peeking).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("back-compat", () => {
|
||||
|
|
|
|||
|
|
@ -112,11 +112,36 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const mql = window.matchMedia(PEEK_POINTER_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setPointerCanPeek(e.matches);
|
||||
// Latch on only — see the runtime detection below for why this never flips
|
||||
// back to false.
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
if (e.matches) setPointerCanPeek(true);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
// iPadOS Safari does not flip the `(hover: hover) and (pointer: fine)` media
|
||||
// query when a trackpad/mouse is attached, so the query above stays false even
|
||||
// though a real cursor is driving the UI — and hover-peek never triggers
|
||||
// (PAP-10725). Detect a fine pointer at runtime instead: a genuine
|
||||
// mouse/trackpad emits pointer events with `pointerType: "mouse"`, whereas
|
||||
// touch reports "touch" and the Pencil reports "pen", so this never enables on
|
||||
// touch-only input. Treat peek capability as a one-way latch — once a cursor
|
||||
// has been seen we keep peek available for the session.
|
||||
useEffect(() => {
|
||||
if (pointerCanPeek || typeof window.PointerEvent !== "function") return;
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
if (e.pointerType === "mouse") setPointerCanPeek(true);
|
||||
};
|
||||
window.addEventListener("pointerover", onPointer, { passive: true });
|
||||
window.addEventListener("pointermove", onPointer, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointerover", onPointer);
|
||||
window.removeEventListener("pointermove", onPointer);
|
||||
};
|
||||
}, [pointerCanPeek]);
|
||||
|
||||
// Precedence (highest wins): forced (active secondary sidebar) > explicit user
|
||||
// pin > route request > default expanded. The force is ephemeral and never
|
||||
// touches the persisted pin, so dropping it restores the user's preference.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
const INBOX_BADGE_ISSUE_LIMIT = 500;
|
||||
const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200;
|
||||
const INBOX_BADGE_HOT_PATH_STALE_MS = 30_000;
|
||||
|
||||
export function useDismissedInboxAlerts() {
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
|
||||
|
|
@ -184,6 +185,8 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||
limit: INBOX_BADGE_ISSUE_LIMIT,
|
||||
}),
|
||||
enabled: !!companyId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: INBOX_BADGE_HOT_PATH_STALE_MS,
|
||||
});
|
||||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
|
|
@ -191,8 +194,10 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||
|
||||
const { data: heartbeatRuns = [] } = useQuery({
|
||||
queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT],
|
||||
queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT),
|
||||
queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT, { summary: true }),
|
||||
enabled: !!companyId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: INBOX_BADGE_HOT_PATH_STALE_MS,
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
|
|
|
|||
|
|
@ -68,7 +68,13 @@ describe("company skill routes", () => {
|
|||
expect(parseSkillRoute("paperclip/deep-research/files/references/setup%20guide.md")).toEqual({
|
||||
skillToken: "paperclip/deep-research",
|
||||
filePath: "references/setup guide.md",
|
||||
hasExplicitFilePath: true,
|
||||
});
|
||||
expect(parseSkillRoute(undefined)).toEqual({ skillToken: null, filePath: "SKILL.md" });
|
||||
expect(parseSkillRoute("diataxis/files/SKILL.md")).toEqual({
|
||||
skillToken: "diataxis",
|
||||
filePath: "SKILL.md",
|
||||
hasExplicitFilePath: true,
|
||||
});
|
||||
expect(parseSkillRoute(undefined)).toEqual({ skillToken: null, filePath: "SKILL.md", hasExplicitFilePath: false });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type CompanySkillRouteSubject = Pick<CompanySkill | CompanySkillDetail |
|
|||
export type ParsedCompanySkillRoute = {
|
||||
skillToken: string | null;
|
||||
filePath: string;
|
||||
hasExplicitFilePath: boolean;
|
||||
};
|
||||
|
||||
export type CompanySkillRouteResolution = {
|
||||
|
|
@ -54,19 +55,21 @@ function decodeSkillRouteToken(tokenPath: string | undefined) {
|
|||
export function parseSkillRoute(routePath: string | undefined): ParsedCompanySkillRoute {
|
||||
const segments = (routePath ?? "").split("/").filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return { skillToken: null, filePath: "SKILL.md" };
|
||||
return { skillToken: null, filePath: "SKILL.md", hasExplicitFilePath: false };
|
||||
}
|
||||
|
||||
const filesIndex = segments.indexOf("files");
|
||||
const hasExplicitFilePath = filesIndex >= 0;
|
||||
const tokenSegments = filesIndex >= 0 ? segments.slice(0, filesIndex) : segments;
|
||||
const skillToken = decodeSkillRouteToken(tokenSegments.join("/"));
|
||||
if (!skillToken) {
|
||||
return { skillToken: null, filePath: "SKILL.md" };
|
||||
return { skillToken: null, filePath: "SKILL.md", hasExplicitFilePath };
|
||||
}
|
||||
|
||||
return {
|
||||
skillToken,
|
||||
filePath: filesIndex >= 0 ? decodeSkillFilePath(segments.slice(filesIndex + 1).join("/")) : "SKILL.md",
|
||||
hasExplicitFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared";
|
|||
import {
|
||||
buildAssistantPartsFromTranscript,
|
||||
buildIssueChatMessages,
|
||||
isCoTSegmentActive,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
|
|
@ -238,6 +239,24 @@ describe("buildAssistantPartsFromTranscript", () => {
|
|||
}]);
|
||||
});
|
||||
|
||||
it("marks only the latest chain-of-thought segment active while a run is live", () => {
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 0,
|
||||
segmentCount: 2,
|
||||
})).toBe(false);
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
})).toBe(true);
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: false,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps run errors while suppressing init and system transcript noise", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -538,6 +538,17 @@ export interface SegmentTiming {
|
|||
endMs: number;
|
||||
}
|
||||
|
||||
export function isCoTSegmentActive(args: {
|
||||
isMessageRunning: boolean;
|
||||
segmentIndex: number;
|
||||
segmentCount: number;
|
||||
}) {
|
||||
const { isMessageRunning, segmentIndex, segmentCount } = args;
|
||||
if (!isMessageRunning) return false;
|
||||
if (segmentCount <= 0 || segmentIndex < 0) return true;
|
||||
return segmentIndex === segmentCount - 1;
|
||||
}
|
||||
|
||||
function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): SegmentTiming[] {
|
||||
const timings: SegmentTiming[] = [];
|
||||
let inSegment = false;
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ export const queryKeys = {
|
|||
runs: (id: string) => ["routines", "runs", id] as const,
|
||||
revisions: (id: string) => ["routines", "revisions", id] as const,
|
||||
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
|
||||
documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") =>
|
||||
["routines", "document-annotations", routineId, key, status] as const,
|
||||
},
|
||||
executionWorkspaces: {
|
||||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
|
|
|
|||
|
|
@ -3672,7 +3672,7 @@ export function CompanySkills() {
|
|||
: "all";
|
||||
const detailTab: SkillDetailTab = (["overview", "files", "versions", "agents"] as SkillDetailTab[]).includes(tabParam as SkillDetailTab)
|
||||
? (tabParam as SkillDetailTab)
|
||||
: selectedPath !== "SKILL.md"
|
||||
: parsedRoute.hasExplicitFilePath || selectedPath !== "SKILL.md"
|
||||
? "files"
|
||||
: "overview";
|
||||
const discoveryCategory = searchParams.get("category");
|
||||
|
|
|
|||
|
|
@ -103,9 +103,6 @@ import {
|
|||
Search,
|
||||
ListTree,
|
||||
} from "lucide-react";
|
||||
|
||||
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
|
||||
const INBOX_ISSUE_LIST_LIMIT = 500;
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
|
|
@ -158,6 +155,10 @@ import {
|
|||
} from "../lib/inbox";
|
||||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
|
||||
const INBOX_ISSUE_LIST_LIMIT = 500;
|
||||
const INBOX_HOT_PATH_STALE_MS = 30_000;
|
||||
|
||||
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
|
||||
export { IssueGroupHeader as InboxGroupHeader } from "../components/IssueGroupHeader";
|
||||
type SectionKey =
|
||||
|
|
@ -804,6 +805,8 @@ export function Inbox() {
|
|||
limit: INBOX_ISSUE_LIST_LIMIT,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: INBOX_HOT_PATH_STALE_MS,
|
||||
});
|
||||
const {
|
||||
data: mineIssuesRaw = [],
|
||||
|
|
@ -819,6 +822,8 @@ export function Inbox() {
|
|||
limit: INBOX_ISSUE_LIST_LIMIT,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: INBOX_HOT_PATH_STALE_MS,
|
||||
});
|
||||
const {
|
||||
data: touchedIssuesRaw = [],
|
||||
|
|
@ -833,12 +838,16 @@ export function Inbox() {
|
|||
limit: INBOX_ISSUE_LIST_LIMIT,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: INBOX_HOT_PATH_STALE_MS,
|
||||
});
|
||||
|
||||
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
|
||||
queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", INBOX_HEARTBEAT_RUN_LIMIT],
|
||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT),
|
||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT, { summary: true }),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: INBOX_HOT_PATH_STALE_MS,
|
||||
});
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationThreadWithComments,
|
||||
DocumentAnnotationThreadStatus,
|
||||
DocumentAnnotationAnchorState,
|
||||
} from "@paperclipai/shared";
|
||||
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
|
||||
import type { PendingAnchor } from "@/components/DocumentAnnotationLayer";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const issueId = "issue-doc-comments";
|
||||
const documentKey = "plan";
|
||||
const currentUserId = "user-board";
|
||||
|
||||
const userProfileMap = new Map<string, CompanyUserProfile>([
|
||||
[currentUserId, { label: "Dotta", image: null }],
|
||||
["user-pm", { label: "Mara Product", image: null }],
|
||||
]);
|
||||
|
||||
function makeThread(
|
||||
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||
): DocumentAnnotationThreadWithComments {
|
||||
const id = overrides.id ?? "thread-1";
|
||||
const status: DocumentAnnotationThreadStatus = overrides.status ?? "open";
|
||||
const anchorState: DocumentAnnotationAnchorState = overrides.anchorState ?? "active";
|
||||
return {
|
||||
id,
|
||||
companyId: "co-1",
|
||||
issueId,
|
||||
documentId: "doc-1",
|
||||
documentKey,
|
||||
status,
|
||||
anchorState,
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: "rev-4",
|
||||
originalRevisionNumber: 4,
|
||||
currentRevisionId: "rev-4",
|
||||
currentRevisionNumber: 4,
|
||||
selectedText:
|
||||
"the assistant should keep the existing editor selection highlighted while the comment composer is open",
|
||||
prefixText: "We agreed ",
|
||||
suffixText: ".",
|
||||
normalizedStart: 0,
|
||||
normalizedEnd: 22,
|
||||
markdownStart: 0,
|
||||
markdownEnd: 22,
|
||||
anchorSelector: {
|
||||
quote: { exact: "selection", prefix: "We ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: currentUserId,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-06-12T00:01:00Z"),
|
||||
updatedAt: new Date("2026-06-12T00:02:00Z"),
|
||||
comments: [
|
||||
{
|
||||
id: `${id}-c1`,
|
||||
companyId: "co-1",
|
||||
threadId: id,
|
||||
issueId,
|
||||
documentId: "doc-1",
|
||||
body: "Please confirm this is still the behaviour we want.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-pm",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-06-12T00:01:00Z"),
|
||||
updatedAt: new Date("2026-06-12T00:01:00Z"),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingAnchor: PendingAnchor = {
|
||||
selector: {
|
||||
quote: { exact: "extremely snappy", prefix: "feels ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 16, markdownStart: 0, markdownEnd: 16 },
|
||||
},
|
||||
selectedText:
|
||||
"submission of comments should be optimistic so the whole interaction feels extremely snappy",
|
||||
};
|
||||
|
||||
function PanelFrame({
|
||||
label,
|
||||
threads,
|
||||
focusedThreadId = null,
|
||||
pending = null,
|
||||
}: {
|
||||
label: string;
|
||||
threads: DocumentAnnotationThreadWithComments[];
|
||||
focusedThreadId?: string | null;
|
||||
pending?: PendingAnchor | null;
|
||||
}) {
|
||||
const [client] = useState(() => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(queryKeys.auth.session, {
|
||||
session: { id: "sess-1", userId: currentUserId },
|
||||
user: { id: currentUserId, email: "dotta@magicmachine.co", name: "Dotta", image: null },
|
||||
});
|
||||
return qc;
|
||||
});
|
||||
const [focused, setFocused] = useState<string | null>(focusedThreadId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="paperclip-story__label">{label}</div>
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="h-[460px]">
|
||||
<DocumentAnnotationPanel
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
issueId={issueId}
|
||||
documentKey={documentKey}
|
||||
documentRevisionNumber={4}
|
||||
baseRevisionId="rev-4"
|
||||
baseRevisionNumber={4}
|
||||
threads={threads}
|
||||
focusedThreadId={focused}
|
||||
onFocusThread={setFocused}
|
||||
focusedCommentId={null}
|
||||
pendingAnchor={pending}
|
||||
onClearPendingAnchor={() => undefined}
|
||||
desktopWidth={360}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentCommentsMatrix() {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner">
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="paperclip-story__label">Document comments · PAP-10960</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">Simplified annotation panel</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
No header, no filter chips, no empty-state copy. Revision indicator sits top-right.
|
||||
The composer shows the author avatar + name, two-line clamped quote, and a plain
|
||||
"Reply" affordance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-6 p-5">
|
||||
<PanelFrame label="No comments yet" threads={[]} />
|
||||
<PanelFrame
|
||||
label="With comments (one expanded)"
|
||||
threads={[
|
||||
makeThread({ id: "thread-1" }),
|
||||
makeThread({ id: "thread-2", status: "resolved", selectedText: "a resolved thread stays in the same list" }),
|
||||
]}
|
||||
focusedThreadId="thread-1"
|
||||
/>
|
||||
<PanelFrame label="Composing a new comment" threads={[makeThread({ id: "thread-1" })]} pending={pendingAnchor} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Document Comments",
|
||||
component: DocumentCommentsMatrix,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Inline document comment panel (PAP-10960): simplified to drop the header, filter chips, and empty-state, surface the author identity in the composer, and clamp quotes to two lines.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof DocumentCommentsMatrix>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Panel: Story = {};
|
||||
|
|
@ -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<string, unknown>) {
|
|||
} 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<RoutineSectionKey, string> = {
|
|||
history: "History",
|
||||
};
|
||||
|
||||
function SectionBody({ section }: { section: RoutineSectionKey }) {
|
||||
function SectionBody({
|
||||
section,
|
||||
descriptionAnnotationsInitiallyOpen = false,
|
||||
}: {
|
||||
section: RoutineSectionKey;
|
||||
descriptionAnnotationsInitiallyOpen?: boolean;
|
||||
}) {
|
||||
switch (section) {
|
||||
case "overview":
|
||||
return <OverviewSection />;
|
||||
return <OverviewSection defaultDescriptionAnnotationsOpen={descriptionAnnotationsInitiallyOpen} />;
|
||||
case "triggers":
|
||||
return <TriggersSection />;
|
||||
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<RoutineSectionKey>(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
|
|||
<main className="min-w-0 flex-1 px-4 pb-6 pt-10 md:px-8">
|
||||
<section className={isEditable ? "mx-auto w-full max-w-3xl" : "w-full"}>
|
||||
<h2 className="mb-4 text-lg font-semibold">{SECTION_TITLES[section]}</h2>
|
||||
<SectionBody section={section} />
|
||||
<SectionBody
|
||||
section={section}
|
||||
descriptionAnnotationsInitiallyOpen={withDescriptionAnnotations}
|
||||
/>
|
||||
{isEditable ? (
|
||||
<RoutineSaveBar
|
||||
dirtyFields={ctx.sectionDirtyFields(section)}
|
||||
|
|
@ -333,6 +448,9 @@ export default meta;
|
|||
type Story = StoryObj<typeof RoutineCShell>;
|
||||
|
||||
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" } };
|
||||
|
|
|
|||
Loading…
Reference in New Issue