Merge pull request #7553 from paperclipai/codex/pap-10343-operator-qol-pr
[codex] Group operator QoL fixes
This commit is contained in:
@@ -722,6 +722,7 @@ export interface AskUserQuestionsPayload {
|
||||
export interface AskUserQuestionsAnswer {
|
||||
questionId: string;
|
||||
optionIds: string[];
|
||||
otherText?: string | null;
|
||||
}
|
||||
|
||||
export interface AskUserQuestionsResult {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Executable
+289
@@ -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."
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<IssueRow, "id" | "identifier" | "title" | "status" |
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlanDocumentCondition(companyId: string, hasPlanDocument: boolean): SQL {
|
||||
const existsPlanDocument = sql<boolean>`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueDocuments}
|
||||
WHERE ${issueDocuments.companyId} = ${companyId}
|
||||
AND ${issueDocuments.issueId} = ${issues.id}
|
||||
AND ${issueDocuments.key} = 'plan'
|
||||
)
|
||||
`;
|
||||
return hasPlanDocument ? existsPlanDocument : sql<boolean>`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<number>`count(*)` })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="paperclip-doc-annotation-sheet flex max-h-[88vh] flex-col rounded-none border-t border-border bg-background p-0"
|
||||
className="paperclip-doc-annotation-sheet z-[60] flex max-h-[88vh] flex-col rounded-none border-t border-border bg-popover p-0 text-popover-foreground shadow-2xl"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
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) {
|
||||
<>
|
||||
<header
|
||||
data-testid={bodyTestId}
|
||||
className="flex items-start justify-between gap-2 border-b border-border px-3 py-2.5"
|
||||
className="flex items-start justify-between gap-2 border-b border-border bg-popover px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-sm font-medium">Comments</p>
|
||||
@@ -227,7 +227,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
|
||||
<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;
|
||||
@@ -255,12 +255,12 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
{props.newCommentDisabled && props.newCommentDisabledReason ? (
|
||||
<p
|
||||
data-testid="document-annotation-disabled-reason"
|
||||
className="border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
className="border-b border-border bg-muted px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{props.newCommentDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<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.`}
|
||||
@@ -302,8 +302,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
)}
|
||||
</div>
|
||||
{props.pendingAnchor ? (
|
||||
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
||||
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-background px-2 py-1 text-xs italic text-muted-foreground">
|
||||
<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">
|
||||
{truncate(props.pendingAnchor.selectedText, 160)}
|
||||
</blockquote>
|
||||
<Textarea
|
||||
@@ -389,9 +389,9 @@ function ThreadCard(props: {
|
||||
data-focused={props.expanded || undefined}
|
||||
aria-labelledby={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"rounded-none border border-border bg-card transition-colors",
|
||||
"rounded-none border border-border bg-background transition-colors",
|
||||
props.expanded && "ring-1 ring-ring/70",
|
||||
thread.status === "resolved" && "bg-muted/30",
|
||||
thread.status === "resolved" && "bg-muted",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={props.onFocus}
|
||||
@@ -405,8 +405,8 @@ function ThreadCard(props: {
|
||||
<blockquote
|
||||
id={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted/40 px-2 py-1 text-xs italic text-muted-foreground",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted/30",
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted",
|
||||
)}
|
||||
>
|
||||
{truncate(thread.selectedText, 120)}
|
||||
|
||||
@@ -60,8 +60,6 @@ function makeAttachment(overrides: Partial<IssueAttachment> = {}): IssueAttachme
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
contentPath: "/api/attachments/attachment-1/content",
|
||||
openPath: "/api/attachments/attachment-1/content",
|
||||
downloadPath: "/api/attachments/attachment-1/content?download=1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -271,8 +269,6 @@ describe("IssueAttachmentsSection", () => {
|
||||
originalFilename: "report.pdf",
|
||||
contentType: "application/pdf",
|
||||
contentPath: "/api/attachments/pdf-attachment/content",
|
||||
openPath: "/api/attachments/pdf-attachment/content",
|
||||
downloadPath: "/api/attachments/pdf-attachment/content?download=1",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -1627,6 +1627,70 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("submits Other text for pending question interactions", async () => {
|
||||
const root = createRoot(container);
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
interactions={[createQuestionInteraction()]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const otherButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Other"),
|
||||
);
|
||||
expect(otherButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
otherButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(textarea, "Phase 1 plus docs");
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Submit answers"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "interaction-question-1",
|
||||
kind: "ask_user_questions",
|
||||
}),
|
||||
[{ questionId: "scope", optionIds: [], otherText: "Phase 1 plus docs" }],
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes the cancel callback for pending question interactions", async () => {
|
||||
const root = createRoot(container);
|
||||
const onCancelInteraction = vi.fn(async () => undefined);
|
||||
|
||||
@@ -282,6 +282,7 @@ describe("IssueDocumentAnnotations", () => {
|
||||
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]');
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor?.className).toContain("fixed");
|
||||
expect(anchor?.className).toContain("z-[60]");
|
||||
});
|
||||
|
||||
it("keeps the desktop annotation panel inside the issue content area when properties are visible", async () => {
|
||||
@@ -712,6 +713,8 @@ describe("IssueDocumentAnnotations", () => {
|
||||
expect(sheet).not.toBeNull();
|
||||
expect(sheet?.getAttribute("data-side")).toBe("bottom");
|
||||
expect(sheet?.className).toContain("paperclip-doc-annotation-sheet");
|
||||
expect(sheet?.className).toContain("z-[60]");
|
||||
expect(sheet?.className).toContain("bg-popover");
|
||||
} finally {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
|
||||
@@ -310,7 +310,7 @@ export function IssueDocumentAnnotations({
|
||||
{panelOpen && !isMobile && desktopPanelFrame ? (
|
||||
<div
|
||||
data-testid="document-annotation-panel-anchor"
|
||||
className="pointer-events-auto fixed hidden lg:block"
|
||||
className="pointer-events-auto fixed z-[60] hidden lg:block"
|
||||
style={{
|
||||
left: desktopPanelFrame.left,
|
||||
maxHeight: desktopPanelFrame.maxHeight,
|
||||
|
||||
@@ -89,6 +89,70 @@ describe("IssueThreadInteractionCard", () => {
|
||||
"interaction-questions-default-post-submit-summary-prompt",
|
||||
);
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3);
|
||||
|
||||
const otherLink = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent === "Other",
|
||||
);
|
||||
expect(otherLink?.getAttribute("role")).toBeNull();
|
||||
expect(otherLink?.className).toContain("underline");
|
||||
});
|
||||
|
||||
it("submits written Other answers for pending questions", async () => {
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingAskUserQuestionsInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
});
|
||||
|
||||
const otherButtons = Array.from(host.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Other"),
|
||||
);
|
||||
expect(otherButtons.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
otherButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(textarea, "Keep only the root item open");
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const summaryCheckbox = Array.from(host.querySelectorAll('[role="checkbox"]')).find((button) =>
|
||||
button.textContent?.includes("Inline answer pills"),
|
||||
);
|
||||
await act(async () => {
|
||||
summaryCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Send answers"),
|
||||
);
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "ask_user_questions" }),
|
||||
[
|
||||
{
|
||||
questionId: "collapse-depth",
|
||||
optionIds: [],
|
||||
otherText: "Keep only the root item open",
|
||||
},
|
||||
{
|
||||
questionId: "post-submit-summary",
|
||||
optionIds: ["answers-inline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("only shows question cancellation when a cancel handler is wired", () => {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
|
||||
interface IssueThreadInteractionCardProps {
|
||||
interaction: IssueThreadInteraction;
|
||||
agentMap?: Map<string, Agent>;
|
||||
@@ -662,6 +664,20 @@ function AskUserQuestionsCard({
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [draftOtherAnswers, setDraftOtherAnswers] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, answer.otherText ?? ""]),
|
||||
),
|
||||
);
|
||||
const [otherActiveQuestions, setOtherActiveQuestions] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, true]),
|
||||
),
|
||||
);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
@@ -674,15 +690,45 @@ function AskUserQuestionsCard({
|
||||
]),
|
||||
),
|
||||
);
|
||||
setDraftOtherAnswers(
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, answer.otherText ?? ""]),
|
||||
),
|
||||
);
|
||||
setOtherActiveQuestions(
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, true]),
|
||||
),
|
||||
);
|
||||
}, [interaction.result?.answers]);
|
||||
|
||||
const questions = interaction.payload.questions;
|
||||
const requiredQuestions = questions.filter((question) => question.required);
|
||||
const canSubmit = requiredQuestions.every(
|
||||
(question) => (draftAnswers[question.id] ?? []).length > 0,
|
||||
(question) =>
|
||||
(draftAnswers[question.id] ?? []).length > 0
|
||||
|| (
|
||||
otherActiveQuestions[question.id] === true
|
||||
&& (draftOtherAnswers[question.id]?.trim().length ?? 0) > 0
|
||||
),
|
||||
);
|
||||
|
||||
function toggleOption(questionId: string, optionId: string, selectionMode: "single" | "multi") {
|
||||
if (optionId === OTHER_ANSWER_ID) {
|
||||
setOtherActiveQuestions((current) => ({
|
||||
...current,
|
||||
[questionId]: !current[questionId],
|
||||
}));
|
||||
if (selectionMode === "single") {
|
||||
setDraftAnswers((current) => ({ ...current, [questionId]: [] }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftAnswers((current) => {
|
||||
const existing = current[questionId] ?? [];
|
||||
if (selectionMode === "single") {
|
||||
@@ -693,6 +739,9 @@ function AskUserQuestionsCard({
|
||||
: [...existing, optionId];
|
||||
return { ...current, [questionId]: next };
|
||||
});
|
||||
if (selectionMode === "single") {
|
||||
setOtherActiveQuestions((current) => ({ ...current, [questionId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -701,10 +750,16 @@ function AskUserQuestionsCard({
|
||||
try {
|
||||
await onSubmitInteractionAnswers(
|
||||
interaction,
|
||||
questions.map((question) => ({
|
||||
questionId: question.id,
|
||||
optionIds: draftAnswers[question.id] ?? [],
|
||||
})),
|
||||
questions.map((question) => {
|
||||
const otherText = otherActiveQuestions[question.id] === true
|
||||
? draftOtherAnswers[question.id]?.trim() ?? ""
|
||||
: "";
|
||||
return {
|
||||
questionId: question.id,
|
||||
optionIds: draftAnswers[question.id] ?? [],
|
||||
...(otherText ? { otherText } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -766,23 +821,53 @@ function AskUserQuestionsCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 grid gap-3"
|
||||
role={question.selectionMode === "single" ? "radiogroup" : "group"}
|
||||
aria-labelledby={`${interaction.id}-${question.id}-prompt`}
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<QuestionOptionButton
|
||||
key={option.id}
|
||||
id={`${interaction.id}-${question.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={(draftAnswers[question.id] ?? []).includes(option.id)}
|
||||
selectionMode={question.selectionMode}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, option.id, question.selectionMode)}
|
||||
<div className="mt-3 space-y-3">
|
||||
<div
|
||||
className="grid gap-3"
|
||||
role={question.selectionMode === "single" ? "radiogroup" : "group"}
|
||||
aria-labelledby={`${interaction.id}-${question.id}-prompt`}
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<QuestionOptionButton
|
||||
key={option.id}
|
||||
id={`${interaction.id}-${question.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={(draftAnswers[question.id] ?? []).includes(option.id)}
|
||||
selectionMode={question.selectionMode}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, option.id, question.selectionMode)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id={`${interaction.id}-${question.id}-other`}
|
||||
aria-expanded={otherActiveQuestions[question.id] === true}
|
||||
className={cn(
|
||||
"text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
otherActiveQuestions[question.id]
|
||||
? "text-sky-700 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
|
||||
>
|
||||
Other
|
||||
</button>
|
||||
{otherActiveQuestions[question.id] ? (
|
||||
<Textarea
|
||||
aria-label={`Other answer for ${question.prompt}`}
|
||||
value={draftOtherAnswers[question.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraftOtherAnswers((current) => ({
|
||||
...current,
|
||||
[question.id]: event.target.value,
|
||||
}))}
|
||||
placeholder="Type your answer"
|
||||
className="min-h-24 bg-background text-sm"
|
||||
/>
|
||||
))}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,12 @@ const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
"application/x-binary",
|
||||
]);
|
||||
|
||||
type AttachmentPathLike = {
|
||||
contentPath: string;
|
||||
openPath?: string;
|
||||
downloadPath?: string;
|
||||
};
|
||||
|
||||
function normalizedContentType(attachment: Pick<IssueAttachment, "contentType">) {
|
||||
return attachment.contentType.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
@@ -15,15 +21,11 @@ export function attachmentFilename(attachment: Pick<IssueAttachment, "id" | "ori
|
||||
return attachment.originalFilename ?? attachment.id;
|
||||
}
|
||||
|
||||
export function attachmentOpenPath(
|
||||
attachment: Pick<IssueAttachment, "contentPath" | "openPath">,
|
||||
) {
|
||||
export function attachmentOpenPath(attachment: AttachmentPathLike) {
|
||||
return attachment.openPath ?? attachment.contentPath;
|
||||
}
|
||||
|
||||
export function attachmentDownloadPath(
|
||||
attachment: Pick<IssueAttachment, "contentPath" | "downloadPath">,
|
||||
) {
|
||||
export function attachmentDownloadPath(attachment: AttachmentPathLike) {
|
||||
return attachment.downloadPath ?? `${attachment.contentPath}?download=1`;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,10 +141,11 @@ describe("issue thread interaction helpers", () => {
|
||||
{
|
||||
questionId: "question-1",
|
||||
optionIds: ["option-2", "option-1"],
|
||||
otherText: "A written answer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(labels).toEqual(["Option 2", "Option 1"]);
|
||||
expect(labels).toEqual(["Option 2", "Option 1", "Other: A written answer"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,12 +132,15 @@ export function getQuestionAnswerLabels(args: {
|
||||
answers: readonly AskUserQuestionsAnswer[];
|
||||
}) {
|
||||
const { question, answers } = args;
|
||||
const selectedIds =
|
||||
answers.find((answer) => answer.questionId === question.id)?.optionIds ?? [];
|
||||
const answer = answers.find((candidate) => candidate.questionId === question.id);
|
||||
const selectedIds = answer?.optionIds ?? [];
|
||||
const optionLabelById = new Map(
|
||||
question.options.map((option) => [option.id, option.label] as const),
|
||||
);
|
||||
return selectedIds
|
||||
const labels = selectedIds
|
||||
.map((optionId) => optionLabelById.get(optionId))
|
||||
.filter((label): label is string => typeof label === "string");
|
||||
const otherText = answer?.otherText?.trim();
|
||||
if (otherText) labels.push(`Other: ${otherText}`);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@@ -295,6 +295,38 @@ describe("Inbox toolbar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides workspace grouping when isolated workspaces are disabled", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
|
||||
expect(groupButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
groupButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const groupOptions = Array.from(document.body.querySelectorAll("button")).map((button) => button.textContent);
|
||||
expect(groupOptions).not.toContain("Workspace");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs hover with j/k selection on inbox rows", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
|
||||
|
||||
@@ -456,6 +456,86 @@ describe("Routines page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the routines list to project groups sorted by title", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Weekly digest", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-3", title: "Agent review", projectId: "project-2" }),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Project Alpha"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Weekly digest"));
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
|
||||
expect(text.indexOf("Weekly digest")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides archived routines from the routines list", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
|
||||
createRoutine({ id: "routine-2", title: "Archived cleanup", status: "archived" }),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("1 routine");
|
||||
expect(text).toContain("Morning sync");
|
||||
expect(text).not.toContain("Archived cleanup");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an outlined row-level run now button on the routines table", async () => {
|
||||
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
@@ -584,6 +664,12 @@ describe("Routines page", () => {
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && issuesListMock.mock.calls.length === 0; attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" });
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -83,9 +83,9 @@ type RoutineGroup = {
|
||||
};
|
||||
|
||||
const defaultRoutineViewState: RoutineViewState = {
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
groupBy: "none",
|
||||
sortField: "title",
|
||||
sortDir: "asc",
|
||||
groupBy: "project",
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
||||
@@ -417,9 +417,13 @@ export function Routines() {
|
||||
[projects],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
const visibleRoutines = useMemo(
|
||||
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
|
||||
[routines],
|
||||
);
|
||||
const sortedRoutines = useMemo(
|
||||
() => sortRoutines(routines ?? [], routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, routines],
|
||||
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
|
||||
);
|
||||
const routineGroups = useMemo(
|
||||
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
@@ -516,7 +520,7 @@ export function Routines() {
|
||||
<TabsContent value="routines" className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
|
||||
{visibleRoutines.length} routine{visibleRoutines.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover>
|
||||
@@ -873,11 +877,11 @@ export function Routines() {
|
||||
|
||||
{activeTab === "routines" ? (
|
||||
<div>
|
||||
{(routines ?? []).length === 0 ? (
|
||||
{visibleRoutines.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
message="No routines yet. Use Create routine to define the first recurring workflow."
|
||||
message="No active routines. Use Create routine to define the first recurring workflow."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user