Merge pull request #7553 from paperclipai/codex/pap-10343-operator-qol-pr

[codex] Group operator QoL fixes
This commit is contained in:
Dotta
2026-06-05 05:29:56 -10:00
committed by GitHub
25 changed files with 896 additions and 68 deletions
+1
View File
@@ -722,6 +722,7 @@ export interface AskUserQuestionsPayload {
export interface AskUserQuestionsAnswer {
questionId: string;
optionIds: string[];
otherText?: string | null;
}
export interface AskUserQuestionsResult {
+1
View File
@@ -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({
+289
View File
@@ -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();
+2
View File
@@ -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",
];
/**
+19
View File
@@ -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({
+23
View File
@@ -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(*)` })
+8
View File
@@ -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",
+4
View File
@@ -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));
+12 -12
View File
@@ -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", () => {
+106 -21
View File
@@ -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>
))}
+8 -6
View File
@@ -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`;
}
+2 -1
View File
@@ -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"]);
});
});
+6 -3
View File
@@ -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;
}
+32
View File
@@ -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" });
+86
View File
@@ -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 () => {
+12 -8
View File
@@ -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>
) : (