diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 46ef3010..20d14342 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -722,6 +722,7 @@ export interface AskUserQuestionsPayload { export interface AskUserQuestionsAnswer { questionId: string; optionIds: string[]; + otherText?: string | null; } export interface AskUserQuestionsResult { diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index cd532dc9..2b8740f5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -666,6 +666,7 @@ export const askUserQuestionsPayloadSchema = z.object({ export const askUserQuestionsAnswerSchema = z.object({ questionId: z.string().trim().min(1).max(120), optionIds: z.array(z.string().trim().min(1).max(120)).max(20), + otherText: multilineTextSchema.pipe(z.string().trim().max(4000)).nullable().optional(), }); export const askUserQuestionsResultSchema = z.object({ diff --git a/scripts/kill-workspaces.sh b/scripts/kill-workspaces.sh new file mode 100755 index 00000000..b1f0f4db --- /dev/null +++ b/scripts/kill-workspaces.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# +# Kill all local Paperclip workspace runtime service processes. +# +# This targets managed workspace services such as preview/dev commands started +# from project or execution workspaces. Use scripts/kill-dev.sh for Paperclip +# server processes. +# +# Usage: +# scripts/kill-workspaces.sh # kill workspace runtime services +# scripts/kill-workspaces.sh --dry # preview what would be killed +# + +set -euo pipefail +shopt -s nullglob + +DRY_RUN=false +for arg in "$@"; do + case "$arg" in + --dry|--dry-run|-n) + DRY_RUN=true + ;; + --help|-h) + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Usage: scripts/kill-workspaces.sh [--dry]" >&2 + exit 2 + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_PARENT="$(dirname "$REPO_ROOT")" +CURRENT_PID="$$" +CURRENT_PGID="$(ps -o pgid= -p "$CURRENT_PID" 2>/dev/null | tr -d '[:space:]' || true)" + +expand_home() { + local value="$1" + if [[ "$value" == "~" ]]; then + printf '%s\n' "$HOME" + elif [[ "$value" == "~/"* ]]; then + printf '%s\n' "$HOME/${value#"~/"}" + else + printf '%s\n' "$value" + fi +} + +runtime_dirs=() +declare -A seen_runtime_dirs=() + +append_runtime_dir() { + local dir="$1" + [[ -d "$dir" ]] || return 0 + dir="$(cd "$dir" && pwd)" + if [[ -z "${seen_runtime_dirs[$dir]:-}" ]]; then + seen_runtime_dirs["$dir"]=1 + runtime_dirs+=("$dir") + fi +} + +paperclip_home="$(expand_home "${PAPERCLIP_HOME:-$HOME/.paperclip}")" +paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" +append_runtime_dir "$paperclip_home/instances/$paperclip_instance_id/runtime-services" + +if [[ "${PAPERCLIP_KILL_WORKSPACES_ONLY_CURRENT:-}" != "1" ]]; then + for dir in \ + "$HOME"/.paperclip/instances/*/runtime-services \ + "$HOME"/.paperclip-worktrees/instances/*/runtime-services \ + "$REPO_ROOT"/.paperclip/instances/*/runtime-services \ + "$REPO_ROOT"/.paperclip/runtime-services/instances/*/runtime-services + do + append_runtime_dir "$dir" + done + + for sibling_root in "$REPO_PARENT"/paperclip*; do + [[ -d "$sibling_root" ]] || continue + for dir in \ + "$sibling_root"/.paperclip/instances/*/runtime-services \ + "$sibling_root"/.paperclip/runtime-services/instances/*/runtime-services + do + append_runtime_dir "$dir" + done + done +fi + +record_lines=() +if [[ ${#runtime_dirs[@]} -gt 0 ]]; then + mapfile -t record_lines < <(node - "${runtime_dirs[@]}" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +function clean(value) { + return String(value ?? "").replace(/[\t\r\n]+/g, " "); +} + +for (const dir of process.argv.slice(2)) { + let entries = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const filePath = path.resolve(dir, entry.name); + let record; + try { + record = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + continue; + } + if (!record || record.profileKind !== "workspace-runtime") continue; + if (!Number.isInteger(record.pid) || record.pid <= 0) continue; + + console.log([ + filePath, + record.serviceKey, + record.serviceName, + record.pid, + Number.isInteger(record.processGroupId) && record.processGroupId > 0 ? record.processGroupId : "", + Number.isInteger(record.port) && record.port > 0 ? record.port : "", + record.cwd, + record.url, + record.runtimeServiceId, + ].map(clean).join("\x1f")); + } +} +NODE +) +fi + +is_pid_running() { + local pid="$1" + kill -0 "$pid" 2>/dev/null +} + +is_group_running() { + local pgid="$1" + [[ -n "$pgid" && "$pgid" =~ ^[0-9]+$ && "$pgid" != "1" ]] || return 1 + [[ -z "$CURRENT_PGID" || "$pgid" != "$CURRENT_PGID" ]] || return 1 + kill -0 "-$pgid" 2>/dev/null +} + +target_is_running() { + local type="$1" + local id="$2" + if [[ "$type" == "group" ]]; then + is_group_running "$id" + else + is_pid_running "$id" + fi +} + +signal_target() { + local type="$1" + local id="$2" + local signal="$3" + if [[ "$type" == "group" ]]; then + kill "-$signal" "-$id" 2>/dev/null + else + kill "-$signal" "$id" 2>/dev/null + fi +} + +active_files=() +stale_files=() +display_lines=() +target_keys=() +target_types=() +target_ids=() +declare -A seen_target_keys=() + +for line in "${record_lines[@]}"; do + IFS=$'\x1f' read -r file service_key service_name pid pgid port cwd url runtime_service_id <<< "$line" + + target_type="" + target_id="" + if is_group_running "$pgid"; then + target_type="group" + target_id="$pgid" + elif is_pid_running "$pid" && [[ "$pid" != "$CURRENT_PID" ]]; then + target_type="pid" + target_id="$pid" + fi + + if [[ -z "$target_id" ]]; then + stale_files+=("$file") + continue + fi + + active_files+=("$file") + target_key="$target_type:$target_id" + if [[ -z "${seen_target_keys[$target_key]:-}" ]]; then + seen_target_keys["$target_key"]=1 + target_keys+=("$target_key") + target_types+=("$target_type") + target_ids+=("$target_id") + fi + + short_file="${file/#$HOME\//}" + short_cwd="${cwd/#$HOME\//}" + target_label="$target_type:$target_id" + display_lines+=("$(printf " %-24s pid %-7s target %-14s port %-6s cwd %-45s registry %s" \ + "${service_name:-workspace-runtime}" "$pid" "$target_label" "${port:-"-"}" "${short_cwd:-"-"}" "$short_file")") + if [[ -n "${url:-}" ]]; then + display_lines+=("$(printf " %-24s url %s" "" "$url")") + fi + if [[ -n "${runtime_service_id:-}" ]]; then + display_lines+=("$(printf " %-24s runtimeServiceId %s" "" "$runtime_service_id")") + fi + if [[ -n "${service_key:-}" ]]; then + display_lines+=("$(printf " %-24s serviceKey %s" "" "$service_key")") + fi +done + +if [[ ${#active_files[@]} -eq 0 && ${#stale_files[@]} -eq 0 ]]; then + echo "No Paperclip workspace runtime services found." + exit 0 +fi + +if [[ ${#active_files[@]} -gt 0 ]]; then + echo "Found ${#active_files[@]} Paperclip workspace runtime service record(s):" + echo "" + printf '%s\n' "${display_lines[@]}" + echo "" +fi + +if [[ ${#stale_files[@]} -gt 0 ]]; then + echo "Found ${#stale_files[@]} stale workspace runtime service registry record(s)." + if [[ "$DRY_RUN" == true ]]; then + stale_preview_limit=20 + stale_preview_count=0 + for file in "${stale_files[@]}"; do + if (( stale_preview_count >= stale_preview_limit )); then + remaining=$(( ${#stale_files[@]} - stale_preview_count )) + echo " ... ${remaining} more stale record(s)" + break + fi + echo " stale ${file/#$HOME\//}" + ((stale_preview_count += 1)) + done + fi + echo "" +fi + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run — re-run without --dry to kill these services and remove stale registry records." + exit 0 +fi + +if [[ ${#target_keys[@]} -gt 0 ]]; then + echo "Sending SIGTERM to workspace runtime process targets..." + for i in "${!target_keys[@]}"; do + type="${target_types[$i]}" + id="${target_ids[$i]}" + if signal_target "$type" "$id" "TERM"; then + echo " signaled $type $id" + else + echo " $type $id already gone" + fi + done + + sleep 2 + + for i in "${!target_keys[@]}"; do + type="${target_types[$i]}" + id="${target_ids[$i]}" + if target_is_running "$type" "$id"; then + echo " $type $id still alive, sending SIGKILL..." + signal_target "$type" "$id" "KILL" || true + fi + done +fi + +if [[ ${#active_files[@]} -gt 0 || ${#stale_files[@]} -gt 0 ]]; then + echo "Removing workspace runtime service registry records..." + for file in "${active_files[@]:-}" "${stale_files[@]:-}"; do + [[ -n "$file" ]] || continue + rm -f "$file" + echo " removed ${file/#$HOME\//}" + done +fi + +echo "Done." diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 4bcb2410..3b47cabe 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -1,4 +1,5 @@ import { Readable } from "node:stream"; +import type { IncomingMessage } from "node:http"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -190,6 +191,13 @@ function makeAttachment(contentType: string, originalFilename: string) { }; } +function parseBinaryResponse(res: IncomingMessage, callback: (error: Error | null, body?: Buffer) => void) { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + res.on("end", () => callback(null, Buffer.concat(chunks))); + res.on("error", callback); +} + describe("normalizeIssueAttachmentMaxBytes", () => { it("keeps the process-level attachment cap as the final cap", async () => { const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES; @@ -362,7 +370,10 @@ describe("issue attachment routes", () => { mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html")); const app = await createApp(storage); - const res = await request(app).get("/api/attachments/attachment-1/content"); + const res = await request(app) + .get("/api/attachments/attachment-1/content") + .buffer(true) + .parse(parseBinaryResponse); expect(res.status).toBe(200); expect([ diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 7bf9dfed..0f0692d5 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -453,8 +453,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { companyId, }, created.id, { answers: [ - { questionId: "scope", optionIds: ["phase-1"] }, - { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, + { questionId: "scope", optionIds: [], otherText: "Custom Phase 1" }, + { + questionId: "extras", + optionIds: ["docs", "tests", "docs"], + otherText: " Pair with release notes ", + }, ], summaryMarkdown: "Ship Phase 1 with tests and docs.", }, { @@ -465,8 +469,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(answered.result).toEqual({ version: 1, answers: [ - { questionId: "scope", optionIds: ["phase-1"] }, - { questionId: "extras", optionIds: ["docs", "tests"] }, + { questionId: "scope", optionIds: [], otherText: "Custom Phase 1" }, + { questionId: "extras", optionIds: ["docs", "tests"], otherText: "Pair with release notes" }, ], summaryMarkdown: "Ship Phase 1 with tests and docs.", }); @@ -824,7 +828,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); - it("expires request confirmations opted into user-comment supersede after creation", async () => { + it("expires request confirmations by default when a user comments after creation", async () => { const { companyId, issueId } = await seedConfirmationIssue(); const commentId = randomUUID(); @@ -836,12 +840,17 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Proceed with the current draft?", - supersedeOnUserComment: true, }, }, { userId: "local-board", }); + expect(created).toMatchObject({ + payload: { + supersedeOnUserComment: true, + }, + }); + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ id: issueId, companyId, @@ -866,7 +875,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); - it("keeps request confirmations pending unless user-comment supersede is explicitly enabled", async () => { + it("keeps request confirmations pending when user-comment supersede is explicitly disabled", async () => { const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out"); await interactionsSvc.create({ @@ -877,6 +886,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Proceed with the current draft?", + supersedeOnUserComment: false, }, }, { userId: "local-board", @@ -899,6 +909,40 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(rows[0]?.status).toBe("pending"); }); + it("keeps legacy request confirmations pending when comment supersede was not stored", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Legacy confirmation without comment supersede flag"); + + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: { kind: "none" }, + payload: { + version: 1, + prompt: "Proceed with the current draft?", + }, + createdByUserId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(Date.now() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(0); + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + it("does not supersede request confirmations for agent, system, or older user comments", async () => { const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions"); @@ -910,7 +954,6 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Proceed with the current draft?", - supersedeOnUserComment: true, }, }, { userId: "local-board", @@ -966,7 +1009,6 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Proceed with the current draft?", - supersedeOnUserComment: true, }, }, { userId: "local-board", diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 07f5717b..0f4f9e7f 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -149,9 +149,11 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { afterEach(async () => { await db.delete(issueComments); await db.delete(issueRelations); + await db.delete(issueDocuments); await db.delete(issueInboxArchives); await db.delete(activityLog); await db.delete(issues); + await db.delete(documents); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); @@ -386,6 +388,76 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]); }); + it("filters issues by whether they have a plan document", async () => { + const companyId = randomUUID(); + const withPlanId = randomUUID(); + const withoutPlanId = randomUUID(); + const otherDocumentId = randomUUID(); + const planDocumentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: withPlanId, + companyId, + title: "Issue with plan", + status: "todo", + priority: "medium", + }, + { + id: withoutPlanId, + companyId, + title: "Issue without plan", + status: "todo", + priority: "medium", + }, + ]); + + await db.insert(documents).values([ + { + id: planDocumentId, + companyId, + title: null, + format: "markdown", + latestBody: "# Plan", + }, + { + id: otherDocumentId, + companyId, + title: "Notes", + format: "markdown", + latestBody: "# Notes", + }, + ]); + + await db.insert(issueDocuments).values([ + { + companyId, + issueId: withPlanId, + documentId: planDocumentId, + key: "plan", + }, + { + companyId, + issueId: withoutPlanId, + documentId: otherDocumentId, + key: "notes", + }, + ]); + + const withPlan = await svc.list(companyId, { hasPlanDocument: true }); + const withoutPlan = await svc.list(companyId, { hasPlanDocument: false }); + + expect(withPlan.map((issue) => issue.id)).toEqual([withPlanId]); + expect(withoutPlan.map((issue) => issue.id)).toEqual([withoutPlanId]); + }); + it("can page issues by most recently updated before priority", async () => { const companyId = randomUUID(); const oldCriticalIssueId = randomUUID(); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 4784ef82..304caf45 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -35,6 +35,7 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "video/mp4", "video/webm", "video/quicktime", + "video/x-m4v", ]; export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream"; @@ -49,6 +50,7 @@ export const INLINE_ATTACHMENT_TYPES: readonly string[] = [ "video/mp4", "video/webm", "video/quicktime", + "video/x-m4v", ]; /** diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 85492638..e31505c4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1193,6 +1193,13 @@ export function issueRoutes( return value === true || value === "true" || value === "1"; } + function parseOptionalBooleanQuery(value: unknown) { + if (value === undefined) return undefined; + if (value === true || value === "true" || value === "1") return true; + if (value === false || value === "false" || value === "0") return false; + return null; + } + function shouldIncludeDocumentAnnotations(req: Request) { if (req.query.includeAnnotations === "false" || req.query.includeAnnotations === "0") return false; return req.actor.type === "agent" || parseBooleanQuery(req.query.includeAnnotations); @@ -1993,6 +2000,7 @@ export function issueRoutes( const attention = req.query.attention as string | undefined; const sortField = req.query.sortField as string | undefined; const sortDir = req.query.sortDir as string | undefined; + const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -2030,6 +2038,10 @@ export function issueRoutes( res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" }); return; } + if (hasPlanDocument === null) { + res.status(400).json({ error: "hasPlanDocument must be true or false when provided" }); + return; + } const offset = parsedOffset ?? 0; const result = await svc.list(companyId, { @@ -2059,6 +2071,7 @@ export function issueRoutes( includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", includeBlockedInboxAttention: req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1", + hasPlanDocument, q: req.query.q as string | undefined, limit, offset, @@ -2094,6 +2107,7 @@ export function issueRoutes( const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const attention = req.query.attention as string | undefined; + const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); if (attention !== "blocked") { res.status(400).json({ error: "issues/count currently requires attention=blocked" }); return; @@ -2102,6 +2116,10 @@ export function issueRoutes( res.status(400).json({ error: "issues/count does not accept limit or offset" }); return; } + if (hasPlanDocument === null) { + res.status(400).json({ error: "hasPlanDocument must be true or false when provided" }); + return; + } const count = await svc.count(companyId, { attention: "blocked", @@ -2126,6 +2144,7 @@ export function issueRoutes( req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1", includeBlockedBy: true, includeBlockedInboxAttention: true, + hasPlanDocument, q: req.query.q as string | undefined, }); res.json({ count }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 73999e00..4f84272c 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -162,6 +162,17 @@ function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestCon return interaction.payload.supersedeOnUserComment === true; } +function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction { + if (input.kind !== "request_confirmation") return input; + return { + ...input, + payload: { + ...input.payload, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + }, + }; +} + function isCommentAtOrAfterInteraction(args: { commentCreatedAt: Date | string; interactionCreatedAt: Date | string; @@ -272,15 +283,20 @@ function normalizeQuestionAnswers(args: { throw unprocessable(`Question ${answer.questionId} only allows one answer`); } + const otherText = answer.otherText?.trim() ?? ""; answerByQuestionId.set(answer.questionId, { questionId: answer.questionId, optionIds: uniqueOptionIds, + ...(otherText ? { otherText } : {}), }); } for (const question of args.questions) { const answer = answerByQuestionId.get(question.id); - if (question.required && (!answer || answer.optionIds.length === 0)) { + if ( + question.required + && (!answer || (answer.optionIds.length === 0 && !answer.otherText)) + ) { throw unprocessable(`Question ${question.id} requires an answer`); } } @@ -668,7 +684,7 @@ export function issueThreadInteractionService(db: Db) { input: CreateIssueThreadInteraction, actor: InteractionActor, ) => { - const data = createIssueThreadInteractionSchema.parse(input); + const data = normalizeCreateInteractionInput(createIssueThreadInteractionSchema.parse(input)); if (data.idempotencyKey) { const existing = await getIdempotentInteraction({ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 32472bcf..58af3c88 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -241,6 +241,7 @@ export interface IssueFilters { includePluginOperations?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + hasPlanDocument?: boolean; q?: string; limit?: number; offset?: number; @@ -2169,6 +2170,19 @@ function issueRef(row: Pick` + EXISTS ( + SELECT 1 + FROM ${issueDocuments} + WHERE ${issueDocuments.companyId} = ${companyId} + AND ${issueDocuments.issueId} = ${issues.id} + AND ${issueDocuments.key} = 'plan' + ) + `; + return hasPlanDocument ? existsPlanDocument : sql`NOT ${existsPlanDocument}`; +} + function isoDate(value: Date | string | null | undefined): string | null { if (!value) return null; const date = value instanceof Date ? value : new Date(value); @@ -2881,6 +2895,9 @@ async function blockedInboxIssueConditions( if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (filters?.hasPlanDocument !== undefined) { + conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); + } if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); if (filters?.labelId) { const labeledIssueIds = await dbOrTx @@ -3826,6 +3843,9 @@ export function issueService(db: Db) { if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (filters?.hasPlanDocument !== undefined) { + conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); + } if (!shouldIncludePluginOperationIssues(filters)) { conditions.push(nonPluginOperationIssueCondition()); } @@ -3989,6 +4009,9 @@ export function issueService(db: Db) { if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (filters?.hasPlanDocument !== undefined) { + conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); + } if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); const [row] = await db .select({ count: sql`count(*)` }) diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 56bdcf47..91f736f6 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -63,6 +63,14 @@ describe("issuesApi.list", () => { ); }); + it("passes plan document filters through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { hasPlanDocument: false, limit: 25 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?hasPlanDocument=false&limit=25", + ); + }); + it("posts recovery action resolution to the source issue endpoint", async () => { await issuesApi.resolveRecoveryAction("issue-1", { actionId: "00000000-0000-0000-0000-0000000000aa", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 55925ed5..ea2bb6c7 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -58,6 +58,7 @@ export const issuesApi = { includeRoutineExecutions?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + hasPlanDocument?: boolean; q?: string; limit?: number; offset?: number; @@ -86,6 +87,9 @@ export const issuesApi = { if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true"); + if (filters?.hasPlanDocument !== undefined) { + params.set("hasPlanDocument", filters.hasPlanDocument ? "true" : "false"); + } if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); if (filters?.offset !== undefined) params.set("offset", String(filters.offset)); diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index c5fbc216..2edad684 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -75,7 +75,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) { Comments on {props.documentKey} revision {props.documentRevisionNumber} @@ -95,7 +95,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) { aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`} data-testid="document-annotation-panel" className={cn( - "flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-card shadow-md", + "isolate flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-popover text-popover-foreground shadow-xl", props.className, )} style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined} @@ -205,7 +205,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { <>

Comments

@@ -227,7 +227,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
-
+
{FILTERS.map((entry) => { const count = counts[entry.id]; const isActive = filter === entry.id; @@ -255,12 +255,12 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { {props.newCommentDisabled && props.newCommentDisabledReason ? (

{props.newCommentDisabledReason}

) : null} -
+
{filteredThreads.length === 0 ? (

{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`} @@ -302,8 +302,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { )}

{props.pendingAnchor ? ( -
-
+
+
{truncate(props.pendingAnchor.selectedText, 160)}