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:
DottaandPaperclip authored and GitHub committed 2026-06-23 09:00:14 -05:00
1 parent 2dbaf4a7fa
commit e68188c438
70 files changed
+4322 -538

No files matched your search

+323
View File
@@ -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,
};
}