Merge pull request #3000 from paperclipai/pap-1167-app-ui-bundle

Improve issue detail workflows, approvals, and board UX
This commit is contained in:
Dotta
2026-04-07 07:31:16 -05:00
committed by GitHub
52 changed files with 15996 additions and 378 deletions
@@ -0,0 +1,209 @@
# 2026-04-06 Sub-issue Creation On Issue Detail Plan
Status: Proposed
Date: 2026-04-06
Audience: Product and engineering
Related:
- `ui/src/pages/IssueDetail.tsx`
- `ui/src/components/IssueProperties.tsx`
- `ui/src/components/NewIssueDialog.tsx`
- `ui/src/context/DialogContext.tsx`
- `packages/shared/src/validators/issue.ts`
- `server/src/services/issues.ts`
## 1. Purpose
This document defines the implementation plan for adding manual sub-issue creation from the issue detail page.
Requested UX:
- the `Sub-issues` tab should always show an `Add sub-issue` action, even when there are no children yet
- the properties pane should also expose a `Sub-issues` section with the same `Add sub-issue` entry point
- both entry points should open the existing new-issue dialog in a "create sub-issue" mode
- the dialog should only show sub-issue-specific UI when it was opened from one of those entry points
This is a UI-first change. The backend already supports child issue creation with `parentId`.
## 2. Current State
### 2.1 Existing child issue display
`ui/src/pages/IssueDetail.tsx` already derives `childIssues` by filtering the company issue list on `parentId === issue.id`.
Current limitation:
- the `Sub-issues` tab only renders the empty state or the child issue list
- there is no action to create a child issue from that tab
### 2.2 Existing properties pane
`ui/src/components/IssueProperties.tsx` shows `Blocked by`, `Blocking`, and `Parent`, but it has no sub-issue section or child issue affordance.
### 2.3 Existing dialog state
`ui/src/context/DialogContext.tsx` can open the global new-issue dialog with defaults such as status, priority, project, assignee, title, and description.
Current limitation:
- there is no way to pass sub-issue context like `parentId`
- `ui/src/components/NewIssueDialog.tsx` therefore cannot submit a child issue or render parent-specific context
### 2.4 Backend contract already exists
The create-issue validator already accepts `parentId`.
`server/src/services/issues.ts` already uses:
- `parentId` for parent-child issue relationships
- `parentId` as the default workspace inheritance source when `inheritExecutionWorkspaceFromIssueId` is not provided
That means the required API and workspace inheritance behavior already exist. No server or schema change is required for the first pass.
## 3. Proposed Implementation
## 3.1 Extend dialog defaults for sub-issue context
Extend `NewIssueDefaults` in `ui/src/context/DialogContext.tsx` with:
- `parentId?: string`
- optional parent display metadata for the dialog header, for example:
- `parentIdentifier?: string`
- `parentTitle?: string`
This keeps the dialog self-contained and avoids re-fetching parent context purely for presentation.
## 3.2 Add issue-detail entry points
Use `openNewIssue(...)` from `ui/src/pages/IssueDetail.tsx` in two places:
1. `Sub-issues` tab
2. properties pane via props passed into `IssueProperties`
Both entry points should pass:
- `parentId: issue.id`
- `parentIdentifier: issue.identifier ?? issue.id`
- `parentTitle: issue.title`
- `projectId: issue.projectId ?? undefined`
Using the current issue's `projectId` preserves the common expectation that sub-issues stay inside the same project unless the operator changes it in the dialog.
No special assignee default should be forced in V1.
## 3.3 Add a dedicated properties-pane section
Extend `IssueProperties` to accept:
- `childIssues: Issue[]`
- `onCreateSubissue: () => void`
Render a new `Sub-issues` section near `Blocked by` / `Blocking`:
- if children exist, show compact links or pills to the existing sub-issues
- always show an `Add sub-issue` button
This keeps the child issue affordance visible in the property area without requiring a generic parent selector.
## 3.4 Update the sub-issues tab layout
Refactor the `Sub-issues` tab in `IssueDetail` to render:
- a small header row with child count
- an `Add sub-issue` button
- the existing empty state or child issue list beneath it
This satisfies the requirement that the action is visible whether or not sub-issues already exist.
## 3.5 Add sub-issue mode to the new-issue dialog
Update `ui/src/components/NewIssueDialog.tsx` so that when `newIssueDefaults.parentId` is present:
- the dialog submits `parentId`
- the header/button copy can switch to `New sub-issue` / `Create sub-issue`
- a compact parent context row is shown, for example `Parent: PAP-1150 add the ability...`
Important constraint:
- this parent context row should only render when the dialog was opened with sub-issue defaults
- opening the dialog from global create actions should remain unchanged and should not expose a generic parent control
That preserves the requested UX boundary: sub-issue creation is intentional, not part of the default create-issue surface.
## 3.6 Query invalidation and refresh behavior
No new data-fetch path is needed.
The existing create success handler in `NewIssueDialog` already invalidates:
- `queryKeys.issues.list(companyId)`
- issue-related list badges
That should be enough for the parent `IssueDetail` view to recompute `childIssues` after creation because it derives children from the company issue list query.
If the detail page ever moves away from the full company issue list, this should be revisited, but it does not require additional work for the current architecture.
## 4. Implementation Order
1. Extend `DialogContext` issue defaults with sub-issue fields.
2. Wire `IssueDetail` to open the dialog in sub-issue mode from the `Sub-issues` tab.
3. Extend `IssueProperties` to display child issues and the `Add sub-issue` action.
4. Update `NewIssueDialog` submission and header UI for sub-issue mode.
5. Add UI tests for the new entry points and payload behavior.
## 5. Testing Plan
Add focused UI tests covering:
1. `IssueDetail`
- `Sub-issues` tab shows `Add sub-issue` when there are zero children
- clicking the action opens the dialog with parent defaults
2. `IssueProperties`
- the properties pane renders the sub-issue section
- `Add sub-issue` remains available when there are no child issues
3. `NewIssueDialog`
- when opened with `parentId`, submit payload includes `parentId`
- sub-issue-specific copy appears only in that mode
- when opened normally, no parent UI is shown and payload is unchanged
No backend test expansion is required unless implementation discovers a client/server contract gap.
## 6. Risks And Decisions
### 6.1 Parent metadata source
Decision: pass parent label metadata through dialog defaults instead of making `NewIssueDialog` fetch the parent issue.
Reason:
- less coupling
- no loading state inside the dialog
- simpler tests
### 6.2 Project inheritance
Decision: prefill `projectId` from the parent issue, but keep it editable.
Reason:
- matches expected operator behavior
- avoids silently moving a sub-issue outside the current project by default
### 6.3 Keep parent selection out of the generic dialog
Decision: do not add a freeform parent picker in this change.
Reason:
- the request explicitly wants sub-issue controls only when the flow starts from a sub-issue action
- this keeps the default issue creation surface simpler
## 7. Success Criteria
This plan is complete when an operator can:
1. open any issue detail page
2. click `Add sub-issue` from either the `Sub-issues` tab or the properties pane
3. land in the existing new-issue dialog with clear parent context
4. create the child issue and see it appear under the parent without a page reload
+24
View File
@@ -45,6 +45,11 @@ type TableDefinition = {
tablename: string;
};
type ExtensionDefinition = {
extension_name: string;
schema_name: string;
};
const DRIZZLE_SCHEMA = "drizzle";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
@@ -376,6 +381,25 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emit("");
}
const extensions = await sql<ExtensionDefinition[]>`
SELECT
e.extname AS extension_name,
n.nspname AS schema_name
FROM pg_extension e
JOIN pg_namespace n ON n.oid = e.extnamespace
WHERE e.extname <> 'plpgsql'
ORDER BY e.extname
`;
if (extensions.length > 0) {
emit("-- Extensions");
for (const extension of extensions) {
emitStatement(
`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(extension.extension_name)} WITH SCHEMA ${quoteIdentifier(extension.schema_name)};`,
);
}
emit("");
}
if (sequences.length > 0) {
emit("-- Sequences");
for (const seq of sequences) {
@@ -0,0 +1,5 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint
CREATE INDEX "issue_comments_body_search_idx" ON "issue_comments" USING gin ("body" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "issues_title_search_idx" ON "issues" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "issues_identifier_search_idx" ON "issues" USING gin ("identifier" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "issues_description_search_idx" ON "issues" USING gin ("description" gin_trgm_ops);
File diff suppressed because it is too large Load Diff
@@ -358,6 +358,13 @@
"when": 1775487782768,
"tag": "0050_stiff_luckman",
"breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1775524651831,
"tag": "0051_young_korg",
"breakpoints": true
}
]
}
}
+1
View File
@@ -31,5 +31,6 @@ export const issueComments = pgTable(
table.issueId,
table.createdAt,
),
bodySearchIdx: index("issue_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
}),
);
+3
View File
@@ -76,6 +76,9 @@ export const issues = pgTable(
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
descriptionSearchIdx: index("issues_description_search_idx").using("gin", table.description.op("gin_trgm_ops")),
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
.on(table.companyId, table.originKind, table.originId)
.where(
+6 -1
View File
@@ -200,7 +200,12 @@ export const PROJECT_COLORS = [
"#3b82f6", // blue
] as const;
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const;
export const APPROVAL_TYPES = [
"hire_agent",
"approve_ceo_strategy",
"budget_override_required",
"request_board_approval",
] as const;
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
export const APPROVAL_STATUSES = [
@@ -57,6 +57,24 @@ function createApp() {
return app;
}
function createAgentApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "api_key",
isInstanceAdmin: false,
};
next();
});
app.use("/api", approvalRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -107,4 +125,56 @@ describe("approval routes idempotent retries", () => {
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("lets agents create generic issue-linked board approval requests", async () => {
mockApprovalService.create.mockResolvedValue({
id: "approval-1",
companyId: "company-1",
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
payload: { title: "Approve hosting spend" },
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-04-06T00:00:00.000Z"),
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
});
const res = await request(createAgentApp())
.post("/api/companies/company-1/approvals")
.send({
type: "request_board_approval",
issueIds: ["00000000-0000-0000-0000-000000000001"],
payload: { title: "Approve hosting spend" },
});
expect(res.status).toBe(201);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
decisionNote: null,
}),
);
expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled();
expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith(
"approval-1",
["00000000-0000-0000-0000-000000000001"],
{ agentId: "agent-1", userId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "approval.created",
}),
);
});
});
+30 -2
View File
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import {
parseAllowedTypes,
matchesContentType,
DEFAULT_ALLOWED_TYPES,
INLINE_ATTACHMENT_TYPES,
isInlineAttachmentContentType,
matchesContentType,
normalizeContentType,
parseAllowedTypes,
} from "../attachment-types.js";
describe("parseAllowedTypes", () => {
@@ -95,3 +98,28 @@ describe("matchesContentType", () => {
expect(matchesContentType("application/zip", patterns)).toBe(true);
});
});
describe("normalizeContentType", () => {
it("lowercases and trims explicit types", () => {
expect(normalizeContentType(" Application/Zip ")).toBe("application/zip");
});
it("falls back to octet-stream when the type is missing", () => {
expect(normalizeContentType(undefined)).toBe("application/octet-stream");
expect(normalizeContentType("")).toBe("application/octet-stream");
});
});
describe("isInlineAttachmentContentType", () => {
it("allows the configured inline-safe types", () => {
for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain"]) {
expect(isInlineAttachmentContentType(contentType)).toBe(true);
}
});
it("rejects potentially unsafe or binary download types", () => {
expect(INLINE_ATTACHMENT_TYPES).not.toContain("text/html");
expect(isInlineAttachmentContentType("text/html")).toBe(false);
expect(isInlineAttachmentContentType("application/zip")).toBe(false);
});
});
@@ -0,0 +1,175 @@
import { Readable } from "node:stream";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import type { StorageService } from "../storage/types.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
createAttachment: vi.fn(),
getAttachmentById: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createStorageService(): StorageService {
return {
provider: "local_disk",
putFile: vi.fn(async (input) => ({
provider: "local_disk",
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
contentType: input.contentType,
byteSize: input.body.length,
sha256: "sha256-sample",
originalFilename: input.originalFilename,
})),
getObject: vi.fn(async () => ({
stream: Readable.from(Buffer.from("test")),
contentLength: 4,
})),
headObject: vi.fn(),
deleteObject: vi.fn(),
};
}
function createApp(storage: StorageService) {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, storage));
app.use(errorHandler);
return app;
}
function makeAttachment(contentType: string, originalFilename: string) {
const now = new Date("2026-01-01T00:00:00.000Z");
return {
id: "attachment-1",
companyId: "company-1",
issueId: "11111111-1111-4111-8111-111111111111",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: `issues/issue-1/${originalFilename}`,
contentType,
byteSize: 4,
sha256: "sha256-sample",
originalFilename,
createdByAgentId: null,
createdByUserId: "local-board",
createdAt: now,
updatedAt: now,
};
}
describe("issue attachment routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("accepts zip uploads for issue attachments", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/zip", "bundle.zip"));
const res = await request(createApp(storage))
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" });
expect(res.status).toBe(201);
const putFileCall = vi.mocked(storage.putFile).mock.calls[0]?.[0];
expect(putFileCall).toMatchObject({
companyId: "company-1",
namespace: "issues/11111111-1111-4111-8111-111111111111",
originalFilename: "bundle.zip",
contentType: "application/zip",
});
expect(Buffer.isBuffer(putFileCall?.body)).toBe(true);
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
contentType: "application/zip",
originalFilename: "bundle.zip",
}),
);
expect(res.body.contentType).toBe("application/zip");
});
it("serves html attachments as downloads with nosniff", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
const res = await request(createApp(storage)).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toBe('attachment; filename="report.html"');
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("keeps image attachments inline for previews", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("image/png", "preview.png"));
const res = await request(createApp(storage)).get("/api/attachments/attachment-1/content");
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toBe('inline; filename="preview.png"');
});
});
@@ -249,6 +249,55 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
it("applies result limits to issue search", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const exactIdentifierId = randomUUID();
const titleMatchId = randomUUID();
const descriptionMatchId = randomUUID();
await db.insert(issues).values([
{
id: exactIdentifierId,
companyId,
issueNumber: 42,
identifier: "PAP-42",
title: "Completely unrelated",
status: "todo",
priority: "medium",
},
{
id: titleMatchId,
companyId,
title: "Search ranking issue",
status: "todo",
priority: "medium",
},
{
id: descriptionMatchId,
companyId,
title: "Another item",
description: "Contains the search keyword",
status: "todo",
priority: "medium",
},
]);
const result = await svc.list(companyId, {
q: "search",
limit: 2,
});
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
});
it("accepts issue identifiers through getById", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
@@ -3,6 +3,8 @@ import express from "express";
import request from "supertest";
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
const unknownHostname = "blocked-host.invalid";
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const app = express();
app.use(
@@ -42,15 +44,15 @@ describe("privateHostnameGuard", () => {
it("blocks unknown hostnames with remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.body?.error).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
});
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`);
expect(res.status).toBe(403);
expect(res.text).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`);
}, 20_000);
});
+22 -2
View File
@@ -1,10 +1,10 @@
/**
* Shared attachment content-type configuration.
*
* By default only image types are allowed. Set the
* By default a curated set of image/document/text types are allowed. Set the
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
* comma-separated list of MIME types or wildcard patterns to expand the
* allowed set.
* allowed set for routes that use this allowlist.
*
* Examples:
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
@@ -29,6 +29,17 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"text/html",
];
export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream";
export const SVG_CONTENT_TYPE = "image/svg+xml";
export const INLINE_ATTACHMENT_TYPES: readonly string[] = [
"image/*",
"application/pdf",
"text/plain",
"text/markdown",
"application/json",
"text/csv",
];
/**
* Parse a comma-separated list of MIME type patterns into a normalised array.
* Returns the default image-only list when the input is empty or undefined.
@@ -59,6 +70,15 @@ export function matchesContentType(contentType: string, allowedPatterns: string[
});
}
export function normalizeContentType(contentType: string | null | undefined): string {
const normalized = (contentType ?? "").trim().toLowerCase();
return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE;
}
export function isInlineAttachmentContentType(contentType: string): boolean {
return matchesContentType(contentType, [...INLINE_ATTACHMENT_TYPES]);
}
// ---------- Module-level singletons read once at startup ----------
const allowedPatterns: string[] = parseAllowedTypes(
+23 -8
View File
@@ -47,7 +47,12 @@ import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
import {
isInlineAttachmentContentType,
MAX_ATTACHMENT_BYTES,
normalizeContentType,
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
@@ -341,6 +346,9 @@ export function issueRoutes(
unreadForUserFilterRaw === "me" && req.actor.type === "board"
? req.actor.userId
: unreadForUserFilterRaw;
const rawLimit = req.query.limit as string | undefined;
const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : null;
const limit = parsedLimit ?? undefined;
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
@@ -358,6 +366,10 @@ export function issueRoutes(
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
return;
}
if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
res.status(400).json({ error: "limit must be a positive integer" });
return;
}
const result = await svc.list(companyId, {
status: req.query.status as string | undefined,
@@ -376,6 +388,7 @@ export function issueRoutes(
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
q: req.query.q as string | undefined,
limit,
});
res.json(result);
});
@@ -2108,11 +2121,7 @@ export function issueRoutes(
res.status(400).json({ error: "Missing file field 'file'" });
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
return;
}
const contentType = normalizeContentType(file.mimetype);
if (file.buffer.length <= 0) {
res.status(422).json({ error: "Attachment is empty" });
return;
@@ -2176,11 +2185,17 @@ export function issueRoutes(
assertCompanyAccess(req, attachment.companyId);
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
const responseContentType = normalizeContentType(attachment.contentType || object.contentType);
res.setHeader("Content-Type", responseContentType);
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
res.setHeader("Cache-Control", "private, max-age=60");
res.setHeader("X-Content-Type-Options", "nosniff");
if (responseContentType === SVG_CONTENT_TYPE) {
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
}
const filename = attachment.originalFilename ?? "attachment";
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment";
res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`);
object.stream.on("error", (err) => {
next(err);
+6 -1
View File
@@ -80,6 +80,7 @@ export interface IssueFilters {
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
limit?: number;
}
type IssueRow = typeof issues.$inferSelect;
@@ -911,6 +912,9 @@ export function issueService(db: Db) {
return {
list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.floor(filters.limit))
: undefined;
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
@@ -999,7 +1003,7 @@ export function issueService(db: Db) {
END
`;
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
const rows = await db
const baseQuery = db
.select()
.from(issues)
.where(and(...conditions))
@@ -1009,6 +1013,7 @@ export function issueService(db: Db) {
desc(canonicalLastActivityAt),
desc(issues.updatedAt),
);
const rows = limit === undefined ? await baseQuery : await baseQuery.limit(limit);
const withLabels = await withIssueLabels(db, rows);
const runMap = await activeRunMapForIssues(db, withLabels);
const withRuns = withActiveRuns(withLabels, runMap);
+32
View File
@@ -133,6 +133,37 @@ If a blocker is moved to `cancelled`, it does **not** count as resolved for bloc
When you receive one of these wake reasons, check the issue state and continue the work or mark it done.
## Requesting Board Approval
Agents can create approval requests for arbitrary issue-linked work. Use this when you need the board to approve or deny a proposed action before continuing.
Recommended generic type:
- `request_board_approval` for open-ended approval requests like spend approval, vendor approval, launch approval, or other board decisions
Create the approval and link it to the relevant issue in one call:
```json
POST /api/companies/{companyId}/approvals
{
"type": "request_board_approval",
"requestedByAgentId": "{your-agent-id}",
"issueIds": ["{issue-id}"],
"payload": {
"title": "Approve monthly hosting spend",
"summary": "Estimated cost is $42/month for provider X.",
"recommendedAction": "Approve provider X and continue setup.",
"risks": ["Costs may increase with usage."]
}
}
```
Notes:
- `issueIds` links the approval into the issue thread/UI.
- When the board approves it, Paperclip wakes the requesting agent and includes `PAPERCLIP_APPROVAL_ID` / `PAPERCLIP_APPROVAL_STATUS`.
- Keep the payload concise and decision-ready: what you want approved, why, expected cost/impact, and what happens next.
## Project Setup Workflow (CEO/Manager Common Path)
When asked to set up a new project with workspace config (local folder and/or GitHub repo), use:
@@ -335,6 +366,7 @@ PATCH /api/agents/{agentId}/instructions-path
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
| Release task | `POST /api/issues/:issueId/release` |
| List agents | `GET /api/companies/:companyId/agents` |
| Create approval | `POST /api/companies/:companyId/approvals` |
| List company skills | `GET /api/companies/:companyId/skills` |
| Import company skills | `POST /api/companies/:companyId/skills/import` |
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
+2 -2
View File
@@ -30,7 +30,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lexical/link": "0.35.0",
"lexical": "0.35.0",
"@mdxeditor/editor": "^3.52.4",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
@@ -41,13 +40,14 @@
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"hermes-paperclip-adapter": "^0.2.0",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"hermes-paperclip-adapter": "^0.2.0",
"lexical": "0.35.0",
"lucide-react": "^0.574.0",
"mermaid": "^11.12.0",
"radix-ui": "^1.4.3",
+2
View File
@@ -36,6 +36,7 @@ export const issuesApi = {
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
limit?: number;
},
) => {
const params = new URLSearchParams();
@@ -53,6 +54,7 @@ export const issuesApi = {
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.q) params.set("q", filters.q);
if (filters?.limit) params.set("limit", String(filters.limit));
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
},
+105 -59
View File
@@ -1,10 +1,18 @@
import { CheckCircle2, XCircle, Clock } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Identity } from "./Identity";
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
import {
approvalSubject,
typeIcon,
defaultTypeIcon,
ApprovalPayloadRenderer,
typeLabel,
} from "./ApprovalPayload";
import { timeAgo } from "../lib/timeAgo";
import type { Approval, Agent } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
function statusIcon(status: string) {
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
@@ -21,86 +29,124 @@ export function ApprovalCard({
onReject,
onOpen,
detailLink,
isPending,
isPending = false,
pendingAction = null,
}: {
approval: Approval;
requesterAgent: Agent | null;
onApprove: () => void;
onReject: () => void;
onApprove?: () => void;
onReject?: () => void;
onOpen?: () => void;
detailLink?: string;
isPending: boolean;
isPending?: boolean;
pendingAction?: "approve" | "reject" | null;
}) {
const payload = approval.payload as Record<string, unknown> | null;
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const kindLabel = typeLabel[approval.type] ?? approval.type;
const subject = approvalSubject(payload);
const showResolutionButtons =
Boolean(onApprove && onReject) &&
approval.type !== "budget_override_required" &&
(approval.status === "pending" || approval.status === "revision_requested");
const hasFooter = showResolutionButtons || Boolean(detailLink || onOpen);
return (
<div className="border border-border rounded-lg p-4 space-y-0">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{label}</span>
{requesterAgent && (
<span className="text-xs text-muted-foreground">
requested by <Identity name={requesterAgent.name} size="sm" className="inline-flex" />
</span>
)}
<div className="rounded-xl border border-border/70 bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/80">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className="border-border/70 bg-background/70 px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"
>
{kindLabel}
</Badge>
{requesterAgent && (
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span>Requested by</span>
<Identity name={requesterAgent.name} size="sm" className="inline-flex" />
</div>
)}
</div>
<div className="space-y-1">
<h3 className="text-base font-semibold leading-6 text-foreground">
{subject ?? kindLabel}
</h3>
<p className="text-xs leading-5 text-muted-foreground">
Approval request created {timeAgo(approval.createdAt)}
</p>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{statusIcon(approval.status)}
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
<div className="shrink-0">
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/80 px-2.5 py-1 text-xs text-muted-foreground">
{statusIcon(approval.status)}
<span className="capitalize">{approval.status.replace(/_/g, " ")}</span>
</div>
</div>
</div>
{/* Payload */}
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
<div className="mt-4 border-t border-border/60 pt-4">
<ApprovalPayloadRenderer
type={approval.type}
payload={approval.payload}
hidePrimaryTitle={Boolean(subject)}
/>
</div>
{/* Decision note */}
{approval.decisionNote && (
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
Note: {approval.decisionNote}
<div className="mt-4 rounded-lg border border-border/60 bg-muted/30 px-3.5 py-3 text-xs leading-5 text-muted-foreground">
<span className="font-medium text-foreground">Decision note.</span> {approval.decisionNote}
</div>
)}
{/* Actions */}
{showResolutionButtons && (
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
<Button
size="sm"
className="bg-green-700 hover:bg-green-600 text-white"
onClick={onApprove}
disabled={isPending}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
onClick={onReject}
disabled={isPending}
>
Reject
</Button>
{hasFooter ? (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
<div className="flex flex-wrap items-center gap-2">
{showResolutionButtons && (
<>
<Button
size="sm"
className="bg-green-700 hover:bg-green-600 text-white"
onClick={onApprove}
disabled={isPending}
>
{pendingAction === "approve" ? "Approving..." : "Approve"}
</Button>
<Button
variant="destructive"
size="sm"
onClick={onReject}
disabled={isPending}
>
{pendingAction === "reject" ? "Rejecting..." : "Reject"}
</Button>
</>
)}
</div>
{(detailLink || onOpen) ? (
detailLink ? (
<Link
to={detailLink}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-auto px-2 text-xs text-muted-foreground")}
>
View details
</Link>
) : (
<Button variant="ghost" size="sm" className="h-auto px-2 text-xs text-muted-foreground" onClick={onOpen}>
View details
</Button>
)
) : null}
</div>
)}
<div className="mt-3">
{detailLink ? (
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
<Link to={detailLink}>View details</Link>
</Button>
) : (
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
View details
</Button>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("approvalLabel", () => {
it("uses payload titles for generic board approvals", () => {
expect(
approvalLabel("request_board_approval", {
title: "Reply with an ASCII frog",
}),
).toBe("Board Approval: Reply with an ASCII frog");
});
});
describe("ApprovalPayloadRenderer", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("renders request_board_approval payload fields without falling back to raw JSON", () => {
const root = createRoot(container);
act(() => {
root.render(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
recommendedAction: "Approve the frog reply.",
nextActionOnApproval: "Post the frog comment on the issue.",
risks: ["The frog might be too powerful."],
proposedComment: "(o)<",
}}
/>,
);
});
expect(container.textContent).toContain("Reply with an ASCII frog");
expect(container.textContent).toContain("Board asked for approval before posting the frog.");
expect(container.textContent).toContain("Approve the frog reply.");
expect(container.textContent).toContain("Post the frog comment on the issue.");
expect(container.textContent).toContain("The frog might be too powerful.");
expect(container.textContent).toContain("(o)<");
expect(container.textContent).not.toContain("\"recommendedAction\"");
act(() => {
root.unmount();
});
});
it("can hide the repeated title when the card header already shows it", () => {
const root = createRoot(container);
act(() => {
root.render(
<ApprovalPayloadRenderer
type="request_board_approval"
hidePrimaryTitle
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
}}
/>,
);
});
expect(container.textContent).toContain("Board asked for approval before posting the frog.");
expect(container.textContent).not.toContain("TitleReply with an ASCII frog");
act(() => {
root.unmount();
});
});
});
+116 -3
View File
@@ -5,13 +5,33 @@ export const typeLabel: Record<string, string> = {
hire_agent: "Hire Agent",
approve_ceo_strategy: "CEO Strategy",
budget_override_required: "Budget Override",
request_board_approval: "Board Approval",
};
function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
export function approvalSubject(payload?: Record<string, unknown> | null): string | null {
return firstNonEmptyString(
payload?.title,
payload?.name,
payload?.summary,
payload?.recommendedAction,
);
}
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
const base = typeLabel[type] ?? type;
if (type === "hire_agent" && payload?.name) {
return `${base}: ${String(payload.name)}`;
const subject = approvalSubject(payload);
if (subject) {
return `${base}: ${subject}`;
}
return base;
}
@@ -20,6 +40,7 @@ export const typeIcon: Record<string, typeof UserPlus> = {
hire_agent: UserPlus,
approve_ceo_strategy: Lightbulb,
budget_override_required: ShieldAlert,
request_board_approval: ShieldCheck,
};
export const defaultTypeIcon = ShieldCheck;
@@ -127,8 +148,100 @@ export function BudgetOverridePayload({ payload }: { payload: Record<string, unk
);
}
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
export function BoardApprovalPayload({
payload,
hideTitle = false,
}: {
payload: Record<string, unknown>;
hideTitle?: boolean;
}) {
const nextPayload = hideTitle ? { ...payload, title: undefined } : payload;
return (
<BoardApprovalPayloadContent payload={nextPayload} />
);
}
function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unknown> }) {
const risks = Array.isArray(payload.risks)
? payload.risks
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
: [];
const title = firstNonEmptyString(payload.title);
const summary = firstNonEmptyString(payload.summary);
const recommendedAction = firstNonEmptyString(payload.recommendedAction);
const nextActionOnApproval = firstNonEmptyString(payload.nextActionOnApproval);
const proposedComment = firstNonEmptyString(payload.proposedComment);
return (
<div className="mt-4 space-y-3.5 text-sm">
{title && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Title</p>
<p className="font-medium leading-6 text-foreground">{title}</p>
</div>
)}
{summary && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Summary</p>
<p className="leading-6 text-foreground/90">{summary}</p>
</div>
)}
{recommendedAction && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-amber-700 dark:text-amber-300">
Recommended action
</p>
<p className="mt-1 leading-6 text-foreground">{recommendedAction}</p>
</div>
)}
{nextActionOnApproval && (
<div className="rounded-lg border border-border/60 bg-background/60 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">On approval</p>
<p className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</p>
</div>
)}
{risks.length > 0 && (
<div className="space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Risks</p>
<ul className="space-y-1 text-sm text-muted-foreground">
{risks.map((risk) => (
<li key={risk} className="flex items-start gap-2">
<span className="mt-2 h-1.5 w-1.5 rounded-full bg-muted-foreground/60" />
<span className="leading-6">{risk}</span>
</li>
))}
</ul>
</div>
)}
{proposedComment && (
<div className="space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Proposed comment
</p>
<pre className="max-h-48 overflow-auto rounded-lg border border-border/60 bg-muted/50 px-3.5 py-3 font-mono text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
{proposedComment}
</pre>
</div>
)}
</div>
);
}
export function ApprovalPayloadRenderer({
type,
payload,
hidePrimaryTitle = false,
}: {
type: string;
payload: Record<string, unknown>;
hidePrimaryTitle?: boolean;
}) {
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
if (type === "request_board_approval") {
return <BoardApprovalPayload payload={payload} hideTitle={hidePrimaryTitle} />;
}
return <CeoStrategyPayload payload={payload} />;
}
+3 -3
View File
@@ -60,12 +60,12 @@ export function CommandPalette() {
const { data: issues = [] } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
enabled: !!selectedCompanyId && open && searchQuery.length === 0,
});
const { data: searchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery),
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery }),
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10),
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10 }),
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
});
+91 -1
View File
@@ -4,7 +4,7 @@ import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import type { Agent } from "@paperclipai/shared";
import type { Agent, Approval } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommentThread } from "./CommentThread";
@@ -33,6 +33,25 @@ vi.mock("./InlineEntitySelector", () => ({
InlineEntitySelector: () => null,
}));
vi.mock("./ApprovalCard", () => ({
ApprovalCard: ({
approval,
onApprove,
onReject,
}: {
approval: Approval;
onApprove?: () => void;
onReject?: () => void;
}) => (
<div>
<div>{approval.type}</div>
<div>{String(approval.payload.title ?? "")}</div>
{onApprove ? <button type="button" onClick={onApprove}>Approve</button> : null}
{onReject ? <button type="button" onClick={onReject}>Reject</button> : null}
</div>
),
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: () => null,
}));
@@ -144,4 +163,75 @@ describe("CommentThread", () => {
root.unmount();
});
});
it("renders linked approvals inline in the timeline", () => {
const root = createRoot(container);
const agent: Agent = {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
urlKey: "codexcoder",
role: "engineer",
title: null,
icon: "code",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
const approval: Approval = {
id: "approval-1",
companyId: "company-1",
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
payload: {
title: "Approve hosting spend",
text: "Estimated monthly cost is $42.",
},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-03-11T09:00:00.000Z"),
updatedAt: new Date("2026-03-11T09:00:00.000Z"),
};
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[]}
linkedApprovals={[approval]}
agentMap={new Map([["agent-1", agent]])}
onAdd={async () => {}}
onApproveApproval={async () => {}}
onRejectApproval={async () => {}}
/>
</MemoryRouter>,
);
});
const approvalRow = container.querySelector("#approval-approval-1") as HTMLDivElement | null;
expect(approvalRow).not.toBeNull();
expect(container.textContent).toContain("request_board_approval");
expect(container.textContent).toContain("Approve hosting spend");
expect(container.textContent).toContain("Approve");
expect(container.textContent).toContain("Reject");
act(() => {
root.unmount();
});
});
});
+55 -5
View File
@@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
import { Link, useLocation } from "react-router-dom";
import type {
Agent,
Approval,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
@@ -15,7 +16,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { StatusBadge } from "./StatusBadge";
import { ApprovalCard } from "./ApprovalCard";
import { AgentIcon } from "./AgentIconPicker";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
@@ -50,6 +51,7 @@ interface CommentReassignment {
interface CommentThreadProps {
comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[];
linkedApprovals?: Approval[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -57,6 +59,12 @@ interface CommentThreadProps {
timelineEvents?: IssueTimelineEvent[];
companyId?: string | null;
projectId?: string | null;
onApproveApproval?: (approvalId: string) => Promise<void>;
onRejectApproval?: (approvalId: string) => Promise<void>;
pendingApprovalAction?: {
approvalId: string;
action: "approve" | "reject";
} | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
@@ -375,6 +383,7 @@ function CommentCard({
type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
| { kind: "approval"; id: string; createdAtMs: number; approval: Approval }
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
@@ -447,6 +456,9 @@ const TimelineList = memo(function TimelineList({
currentUserId,
companyId,
projectId,
onApproveApproval,
onRejectApproval,
pendingApprovalAction,
feedbackVoteByTargetId,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -459,6 +471,12 @@ const TimelineList = memo(function TimelineList({
currentUserId?: string | null;
companyId?: string | null;
projectId?: string | null;
onApproveApproval?: (approvalId: string) => Promise<void>;
onRejectApproval?: (approvalId: string) => Promise<void>;
pendingApprovalAction?: {
approvalId: string;
action: "approve" | "reject";
} | null;
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -488,6 +506,24 @@ const TimelineList = memo(function TimelineList({
);
}
if (item.kind === "approval") {
const approval = item.approval;
const isPending = pendingApprovalAction?.approvalId === approval.id;
return (
<div id={`approval-${approval.id}`} key={`approval:${approval.id}`} className="py-1.5">
<ApprovalCard
approval={approval}
requesterAgent={approval.requestedByAgentId ? agentMap?.get(approval.requestedByAgentId) ?? null : null}
onApprove={onApproveApproval ? () => void onApproveApproval(approval.id) : undefined}
onReject={onRejectApproval ? () => void onRejectApproval(approval.id) : undefined}
detailLink={`/approvals/${approval.id}`}
isPending={isPending}
pendingAction={isPending ? pendingApprovalAction?.action ?? null : null}
/>
</div>
);
}
if (item.kind === "run") {
const run = item.run;
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
@@ -548,6 +584,7 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({
comments,
queuedComments = [],
linkedApprovals = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -555,6 +592,9 @@ export function CommentThread({
timelineEvents = [],
companyId,
projectId,
onApproveApproval,
onRejectApproval,
pendingApprovalAction = null,
onVote,
onAdd,
agentMap,
@@ -593,6 +633,12 @@ export function CommentThread({
createdAtMs: new Date(comment.createdAt).getTime(),
comment,
}));
const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({
kind: "approval",
id: approval.id,
createdAtMs: new Date(approval.createdAt).getTime(),
approval,
}));
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
kind: "event",
id: event.id,
@@ -605,17 +651,18 @@ export function CommentThread({
createdAtMs: new Date(runTimestamp(run)).getTime(),
run,
}));
return [...commentItems, ...eventItems, ...runItems].sort((a, b) => {
return [...commentItems, ...approvalItems, ...eventItems, ...runItems].sort((a, b) => {
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
if (a.kind === b.kind) return a.id.localeCompare(b.id);
const kindOrder = {
event: 0,
comment: 1,
run: 2,
approval: 1,
comment: 2,
run: 3,
} as const;
return kindOrder[a.kind] - kindOrder[b.kind];
});
}, [comments, timelineEvents, linkedRuns]);
}, [comments, linkedApprovals, timelineEvents, linkedRuns]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
@@ -754,6 +801,9 @@ export function CommentThread({
currentUserId={currentUserId}
companyId={companyId}
projectId={projectId}
onApproveApproval={onApproveApproval}
onRejectApproval={onRejectApproval}
pendingApprovalAction={pendingApprovalAction}
feedbackVoteByTargetId={feedbackVoteByTargetId}
feedbackDataSharingPreference={feedbackDataSharingPreference}
onVote={onVote ? handleFeedbackVote : undefined}
+265
View File
@@ -0,0 +1,265 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DocumentRevision } from "@paperclipai/shared";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { relativeTime } from "../lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function getRevisionLabel(revision: DocumentRevision) {
const actor = revision.createdByUserId
? "board"
: revision.createdByAgentId
? "agent"
: "system";
return `rev ${revision.revisionNumber}${relativeTime(revision.createdAt)}${actor}`;
}
type DiffRow = {
kind: "context" | "removed" | "added";
oldLineNumber: number | null;
newLineNumber: number | null;
text: string;
};
function buildLineDiff(oldText: string, newText: string): DiffRow[] {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const oldCount = oldLines.length;
const newCount = newLines.length;
const dp = Array.from({ length: oldCount + 1 }, () => Array<number>(newCount + 1).fill(0));
for (let i = oldCount - 1; i >= 0; i -= 1) {
for (let j = newCount - 1; j >= 0; j -= 1) {
dp[i][j] = oldLines[i] === newLines[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const rows: DiffRow[] = [];
let i = 0;
let j = 0;
let oldLineNumber = 1;
let newLineNumber = 1;
while (i < oldCount && j < newCount) {
if (oldLines[i] === newLines[j]) {
rows.push({
kind: "context",
oldLineNumber,
newLineNumber,
text: oldLines[i],
});
i += 1;
j += 1;
oldLineNumber += 1;
newLineNumber += 1;
continue;
}
if (dp[i + 1][j] >= dp[i][j + 1]) {
rows.push({
kind: "removed",
oldLineNumber,
newLineNumber: null,
text: oldLines[i],
});
i += 1;
oldLineNumber += 1;
continue;
}
rows.push({
kind: "added",
oldLineNumber: null,
newLineNumber,
text: newLines[j],
});
j += 1;
newLineNumber += 1;
}
while (i < oldCount) {
rows.push({
kind: "removed",
oldLineNumber,
newLineNumber: null,
text: oldLines[i],
});
i += 1;
oldLineNumber += 1;
}
while (j < newCount) {
rows.push({
kind: "added",
oldLineNumber: null,
newLineNumber,
text: newLines[j],
});
j += 1;
newLineNumber += 1;
}
return rows;
}
export function DocumentDiffModal({
issueId,
documentKey,
latestRevisionNumber,
open,
onOpenChange,
}: {
issueId: string;
documentKey: string;
latestRevisionNumber: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { data: revisions } = useQuery({
queryKey: queryKeys.issues.documentRevisions(issueId, documentKey),
queryFn: () => issuesApi.listDocumentRevisions(issueId, documentKey),
enabled: open,
});
const sortedRevisions = useMemo(() => {
if (!revisions) return [];
return [...revisions].sort((a, b) => b.revisionNumber - a.revisionNumber);
}, [revisions]);
// Default: compare previous (latestRevisionNumber - 1) with current (latestRevisionNumber)
const [leftRevisionId, setLeftRevisionId] = useState<string | null>(null);
const [rightRevisionId, setRightRevisionId] = useState<string | null>(null);
const effectiveLeftId = leftRevisionId ?? sortedRevisions.find(
(r) => r.revisionNumber === latestRevisionNumber - 1,
)?.id ?? null;
const effectiveRightId = rightRevisionId ?? sortedRevisions.find(
(r) => r.revisionNumber === latestRevisionNumber,
)?.id ?? null;
const leftRevision = sortedRevisions.find((r) => r.id === effectiveLeftId) ?? null;
const rightRevision = sortedRevisions.find((r) => r.id === effectiveRightId) ?? null;
const leftBody = leftRevision?.body ?? "";
const rightBody = rightRevision?.body ?? "";
const diffRows = useMemo(() => buildLineDiff(leftBody, rightBody), [leftBody, rightBody]);
const lineClassesByKind: Record<DiffRow["kind"], string> = {
context: "bg-transparent",
removed: "bg-red-500/10 text-red-100",
added: "bg-green-500/10 text-green-100",
};
const markerByKind: Record<DiffRow["kind"], string> = {
context: " ",
removed: "-",
added: "+",
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[90%] w-full max-h-[85vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between gap-4">
<DialogHeader className="shrink-0">
<DialogTitle>
Diff <span className="font-mono text-sm">{documentKey}</span>
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-2">
<span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-red-400">Old</span>
<Select
value={effectiveLeftId ?? ""}
onValueChange={(value) => setLeftRevisionId(value)}
>
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
<SelectValue placeholder="Select revision" />
</SelectTrigger>
<SelectContent>
{sortedRevisions.map((revision) => (
<SelectItem key={revision.id} value={revision.id} className="text-xs">
{getRevisionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-green-400">New</span>
<Select
value={effectiveRightId ?? ""}
onValueChange={(value) => setRightRevisionId(value)}
>
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
<SelectValue placeholder="Select revision" />
</SelectTrigger>
<SelectContent>
{sortedRevisions.map((revision) => (
<SelectItem key={revision.id} value={revision.id} className="text-xs">
{getRevisionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="overflow-auto flex-1 rounded-md border border-border text-xs">
{!revisions ? (
<div className="p-6 text-center text-muted-foreground text-sm">Loading revisions...</div>
) : !leftRevision || !rightRevision ? (
<div className="p-6 text-center text-muted-foreground text-sm">Select two revisions to compare.</div>
) : leftRevision.id === rightRevision.id ? (
<div className="p-6 text-center text-muted-foreground text-sm">Both sides are the same revision.</div>
) : (
<div className="font-mono text-[12px] leading-6">
<div className="grid grid-cols-[56px_56px_24px_minmax(0,1fr)] border-b border-border/60 bg-muted/30 px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>Old</span>
<span>New</span>
<span />
<span>Content</span>
</div>
{diffRows.map((row, index) => (
<div
key={`${row.kind}-${index}-${row.oldLineNumber ?? "x"}-${row.newLineNumber ?? "x"}`}
className={`grid grid-cols-[56px_56px_24px_minmax(0,1fr)] gap-0 border-b border-border/30 px-3 ${lineClassesByKind[row.kind]}`}
>
<span className="select-none border-r border-border/30 pr-3 text-right text-muted-foreground">
{row.oldLineNumber ?? ""}
</span>
<span className="select-none border-r border-border/30 px-3 text-right text-muted-foreground">
{row.newLineNumber ?? ""}
</span>
<span className="select-none px-3 text-center text-muted-foreground">
{markerByKind[row.kind]}
</span>
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-0 text-inherit">
{row.text.length > 0 ? row.text : " "}
</pre>
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+4
View File
@@ -11,6 +11,8 @@ interface InlineEditorProps {
placeholder?: string;
multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise<void>;
mentions?: MentionOption[];
nullable?: boolean;
}
@@ -46,6 +48,7 @@ export function InlineEditor({
multiline = false,
nullable = false,
imageUploadHandler,
onDropFile,
mentions,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
@@ -228,6 +231,7 @@ export function InlineEditor({
className="bg-transparent"
contentClassName={cn("paperclip-edit-in-place-content", className)}
imageUploadHandler={imageUploadHandler}
onDropFile={onDropFile}
mentions={mentions}
onSubmit={() => {
finalizeMultilineBlurOrSubmit();
+23 -1
View File
@@ -29,7 +29,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import { DocumentDiffModal } from "./DocumentDiffModal";
type DraftState = {
key: string;
@@ -162,6 +163,7 @@ export function IssueDocumentsSection({
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
const [diffViewKey, setDiffViewKey] = useState<string | null>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasScrolledToHashRef = useRef(false);
@@ -929,6 +931,12 @@ export function IssueDocumentsSection({
<Download className="h-3.5 w-3.5" />
Download document
</DropdownMenuItem>
{doc.latestRevisionNumber > 1 ? (
<DropdownMenuItem onClick={() => setDiffViewKey(doc.key)}>
<Diff className="h-3.5 w-3.5" />
View diff
</DropdownMenuItem>
) : null}
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
{canDeleteDocuments ? (
<DropdownMenuItem
@@ -1174,6 +1182,20 @@ export function IssueDocumentsSection({
);
})}
</div>
{diffViewKey && (() => {
const diffDoc = sortedDocuments.find((d) => d.key === diffViewKey);
if (!diffDoc) return null;
return (
<DocumentDiffModal
issueId={issue.id}
documentKey={diffDoc.key}
latestRevisionNumber={diffDoc.latestRevisionNumber}
open
onOpenChange={(open) => { if (!open) setDiffViewKey(null); }}
/>
);
})()}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueProperties } from "./IssueProperties";
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
listLabels: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
orderedProjects: projects,
}),
}));
vi.mock("../lib/recent-assignees", () => ({
getRecentAssigneeIds: () => [],
sortAgentsByRecency: (agents: unknown[]) => agents,
trackRecentAssignee: vi.fn(),
}));
vi.mock("../lib/assignees", () => ({
formatAssigneeUserLabel: () => "Me",
}));
vi.mock("./StatusIcon", () => ({
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("./PriorityIcon", () => ({
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
}));
vi.mock("@/components/ui/separator", () => ({
Separator: () => <hr />,
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Parent issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
labelIds: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
...overrides,
};
}
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} />
</QueryClientProvider>,
);
});
return root;
}
describe("IssueProperties", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
});
afterEach(() => {
document.body.innerHTML = "";
});
it("always exposes the add sub-issue action", async () => {
const onAddSubIssue = vi.fn();
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onAddSubIssue,
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Sub-issues");
expect(container.textContent).toContain("Add sub-issue");
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add sub-issue"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAddSubIssue).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
});
+94 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue } from "@paperclipai/shared";
@@ -19,9 +19,40 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, GitBranch, FolderOpen, Copy, Check } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch { /* noop */ }
}, [value]);
return (
<div className="flex items-start gap-1.5 min-w-0 flex-1">
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
<span className="text-sm font-mono min-w-0 break-all">
{value}
</span>
<button
type="button"
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy"}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</button>
</div>
);
}
function defaultProjectWorkspaceIdForProject(project: {
workspaces?: Array<{ id: string; isPrimary: boolean }>;
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
@@ -42,6 +73,8 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
interface IssuePropertiesProps {
issue: Issue;
childIssues?: Issue[];
onAddSubIssue?: () => void;
onUpdate: (data: Record<string, unknown>) => void;
inline?: boolean;
}
@@ -117,7 +150,13 @@ function PropertyPicker({
);
}
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
export function IssueProperties({
issue,
childIssues = [],
onAddSubIssue,
onUpdate,
inline,
}: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const companyId = issue.companyId ?? selectedCompanyId;
@@ -683,6 +722,34 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
)}
</PropertyRow>
<PropertyRow label="Sub-issues">
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0 ? (
childIssues.map((child) => (
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
>
{child.identifier ?? child.title}
</Link>
))
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
{onAddSubIssue ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={onAddSubIssue}
>
<Plus className="h-3 w-3" />
Add sub-issue
</button>
) : null}
</div>
</PropertyRow>
{issue.parentId && (
<PropertyRow label="Parent">
<Link
@@ -700,6 +767,30 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
)}
</div>
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? (
<>
<Separator />
<div className="space-y-1">
{issue.currentExecutionWorkspace?.branchName && (
<PropertyRow label="Branch">
<TruncatedCopyable
value={issue.currentExecutionWorkspace.branchName}
icon={GitBranch}
/>
</PropertyRow>
)}
{issue.currentExecutionWorkspace?.cwd && (
<PropertyRow label="Folder">
<TruncatedCopyable
value={issue.currentExecutionWorkspace.cwd}
icon={FolderOpen}
/>
</PropertyRow>
)}
</div>
</>
) : null}
<Separator />
<div className="space-y-1">
+1 -3
View File
@@ -128,9 +128,7 @@ describe("IssueRow", () => {
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
);
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toBe("/issues/PAP-1");
act(() => {
root.unmount();
+3 -2
View File
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
@@ -51,9 +51,10 @@ export function IssueRow({
return (
<Link
to={createIssueDetailPath(issuePathId, issueLinkState)}
to={createIssueDetailPath(issuePathId)}
state={issueLinkState}
data-inbox-issue-link
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)}
className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
+187
View File
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssuesList } from "./IssuesList";
const companyState = vi.hoisted(() => ({
selectedCompanyId: "company-1",
}));
const dialogState = vi.hoisted(() => ({
openNewIssue: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => companyState,
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("./IssueRow", () => ({
IssueRow: ({ issue }: { issue: Issue }) => <div data-testid="issue-row">{issue.title}</div>,
}));
vi.mock("./KanbanBoard", () => ({
KanbanBoard: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Issue title",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-07T00:00:00.000Z"),
updatedAt: new Date("2026-04-07T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
isUnreadForMe: false,
...overrides,
};
}
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>,
);
});
return { root, queryClient };
}
describe("IssuesList", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
dialogState.openNewIssue.mockReset();
mockIssuesApi.list.mockReset();
mockIssuesApi.listLabels.mockReset();
mockAuthApi.getSession.mockReset();
mockIssuesApi.listLabels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
});
afterEach(() => {
container.remove();
});
it("renders server search results instead of filtering the full issue list locally", async () => {
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
mockIssuesApi.list.mockResolvedValue([serverIssue]);
const { root } = renderWithQueryClient(
<IssuesList
issues={[localIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
initialSearch="server"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", projectId: undefined });
expect(container.textContent).toContain("Server result");
expect(container.textContent).not.toContain("Local issue");
});
act(() => {
root.unmount();
});
});
});
+2 -16
View File
@@ -145,18 +145,6 @@ function countActiveFilters(state: IssueViewState): number {
return count;
}
function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean {
if (!normalizedSearch) return true;
return [
issue.identifier,
issue.title,
issue.description,
]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(normalizedSearch));
}
/* ── Component ── */
interface Agent {
@@ -278,12 +266,10 @@ export function IssuesList({
}, [agents]);
const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0
? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch))
: issues;
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
return sortIssues(filteredByControls, viewState);
}, [issues, viewState, normalizedIssueSearch, currentUserId]);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
+28 -1
View File
@@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
@@ -186,4 +186,31 @@ describe("MarkdownEditor", () => {
left: 92,
});
});
it("keeps a short mention menu on the same line when it fits below the caret", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 160, viewportLeft: 120 },
{ offsetLeft: 0, offsetTop: 0, width: 320, height: 220 },
{ width: 188, height: 42 },
),
).toEqual({
top: 164,
left: 120,
});
});
it("keeps mention queries active across spaces", () => {
expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({
trigger: "mention",
marker: "@",
query: "Paperclip App",
atPos: 5,
endPos: "Ping @Paperclip App".length,
});
});
it("still rejects slash commands once spaces are typed", () => {
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
});
});
+93 -35
View File
@@ -62,6 +62,8 @@ interface MarkdownEditorProps {
contentClassName?: string;
onBlur?: () => void;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
onDropFile?: (file: File) => Promise<void>;
bordered?: boolean;
/** List of mentionable entities. Enables @-mention autocomplete. */
mentions?: MentionOption[];
@@ -108,9 +110,16 @@ interface MentionMenuViewport {
height: number;
}
interface MentionMenuSize {
width: number;
height: number;
}
const MENTION_MENU_WIDTH = 188;
const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8;
const MENTION_MENU_ROW_HEIGHT = 34;
const MENTION_MENU_CHROME_HEIGHT = 8;
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
txt: "Text",
@@ -140,19 +149,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
Editor: CodeMirrorEditor,
};
function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
const range = sel.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return null;
if (!container.contains(textNode)) return null;
const text = textNode.textContent ?? "";
const offset = range.startOffset;
// Walk backwards from cursor to find an autocomplete trigger.
export function findMentionMatch(
text: string,
offset: number,
): Pick<MentionState, "trigger" | "marker" | "query" | "atPos" | "endPos"> | null {
let atPos = -1;
let trigger: MentionState["trigger"] | null = null;
let marker: MentionState["marker"] | null = null;
@@ -166,31 +166,54 @@ function detectMention(container: HTMLElement): MentionState | null {
}
break;
}
if (/\s/.test(ch)) break;
if (ch === "\n" || ch === "\r") break;
}
if (atPos === -1) return null;
const query = text.slice(atPos + 1, offset);
// Get position relative to container
const tempRange = document.createRange();
tempRange.setStart(textNode, atPos);
tempRange.setEnd(textNode, atPos + 1);
const rect = tempRange.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (trigger === "skill" && /\s/.test(query)) return null;
return {
trigger: trigger ?? "mention",
marker: marker ?? "@",
query,
atPos,
endPos: offset,
};
}
function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
const range = sel.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return null;
if (!container.contains(textNode)) return null;
const text = textNode.textContent ?? "";
const offset = range.startOffset;
const match = findMentionMatch(text, offset);
if (!match) return null;
// Get position relative to container
const tempRange = document.createRange();
tempRange.setStart(textNode, match.atPos);
tempRange.setEnd(textNode, match.atPos + 1);
const rect = tempRange.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return {
trigger: match.trigger,
marker: match.marker,
query: match.query,
top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left,
viewportTop: rect.bottom,
viewportLeft: rect.left,
textNode: textNode as Text,
atPos,
endPos: offset,
atPos: match.atPos,
endPos: match.endPos,
};
}
@@ -216,11 +239,12 @@ function getMentionMenuViewport(): MentionMenuViewport {
export function computeMentionMenuPosition(
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
viewport: MentionMenuViewport,
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
) {
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH;
const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width;
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
return {
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
@@ -228,6 +252,17 @@ export function computeMentionMenuPosition(
};
}
function getMentionMenuSize(optionCount: number): MentionMenuSize {
const visibleRows = Math.max(1, Math.min(optionCount, 8));
return {
width: MENTION_MENU_WIDTH,
height: Math.min(
MENTION_MENU_HEIGHT,
visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT,
),
};
}
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
if (!node || !container.contains(node)) return false;
const el = node.nodeType === Node.ELEMENT_NODE
@@ -281,6 +316,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
contentClassName,
onBlur,
imageUploadHandler,
onDropFile,
bordered = true,
mentions,
onSubmit,
@@ -635,6 +671,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}
const canDropImage = Boolean(imageUploadHandler);
const canDropFile = Boolean(imageUploadHandler || onDropFile);
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
const clipboard = event.clipboardData;
if (!clipboard || !ref.current) return;
@@ -650,7 +687,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}, []);
const mentionMenuPosition = mentionState
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
? computeMentionMenuPosition(
mentionState,
getMentionMenuViewport(),
getMentionMenuSize(filteredMentions.length),
)
: null;
return (
@@ -673,8 +714,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Mention keyboard handling
if (mentionActive) {
// Space dismisses the popup (let the character be typed normally)
if (e.key === " ") {
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
mentionStateRef.current = null;
setMentionState(null);
return;
@@ -711,23 +751,41 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}
}}
onDragEnter={(evt) => {
if (!canDropImage || !hasFilePayload(evt)) return;
if (!canDropFile || !hasFilePayload(evt)) return;
dragDepthRef.current += 1;
setIsDragOver(true);
}}
onDragOver={(evt) => {
if (!canDropImage || !hasFilePayload(evt)) return;
if (!canDropFile || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
}}
onDragLeave={() => {
if (!canDropImage) return;
if (!canDropFile) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}}
onDrop={() => {
onDrop={(evt) => {
dragDepthRef.current = 0;
setIsDragOver(false);
if (!onDropFile) return;
const files = evt.dataTransfer?.files;
if (!files || files.length === 0) return;
const allFiles = Array.from(files);
const nonImageFiles = allFiles.filter(
(f) => !f.type.startsWith("image/"),
);
if (nonImageFiles.length === 0) return;
// If all dropped files are non-image, prevent default so MDXEditor
// doesn't try to handle them. If mixed, let images flow through to
// the image plugin and only handle the non-image files ourselves.
if (nonImageFiles.length === allFiles.length) {
evt.preventDefault();
evt.stopPropagation();
}
for (const file of nonImageFiles) {
void onDropFile(file);
}
}}
onPasteCapture={handlePasteCapture}
>
@@ -818,14 +876,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
document.body,
)}
{isDragOver && canDropImage && (
{isDragOver && canDropFile && (
<div
className={cn(
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
!bordered && "inset-0 rounded-sm",
)}
>
Drop image to upload
Drop {onDropFile ? "file" : "image"} to upload
</div>
)}
{uploadError && (
+439
View File
@@ -0,0 +1,439 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NewIssueDialog } from "./NewIssueDialog";
const dialogState = vi.hoisted(() => ({
newIssueOpen: true,
newIssueDefaults: {} as Record<string, unknown>,
closeNewIssue: vi.fn(),
}));
const companyState = vi.hoisted(() => ({
companies: [
{
id: "company-1",
name: "Paperclip",
status: "active",
brandColor: "#123456",
issuePrefix: "PAP",
},
],
selectedCompanyId: "company-1",
selectedCompany: {
id: "company-1",
name: "Paperclip",
status: "active",
brandColor: "#123456",
issuePrefix: "PAP",
},
}));
const toastState = vi.hoisted(() => ({
pushToast: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
create: vi.fn(),
upsertDocument: vi.fn(),
uploadAttachment: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
adapterModels: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockAssetsApi = vi.hoisted(() => ({
uploadImage: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => companyState,
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => toastState,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/assets", () => ({
assetsApi: mockAssetsApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
orderedProjects: projects,
}),
}));
vi.mock("../lib/recent-assignees", () => ({
getRecentAssigneeIds: () => [],
sortAgentsByRecency: (agents: unknown[]) => agents,
trackRecentAssignee: vi.fn(),
}));
vi.mock("../lib/assignees", () => ({
assigneeValueFromSelection: ({
assigneeAgentId,
assigneeUserId,
}: {
assigneeAgentId?: string;
assigneeUserId?: string;
}) => assigneeAgentId ? `agent:${assigneeAgentId}` : assigneeUserId ? `user:${assigneeUserId}` : "",
currentUserAssigneeOption: () => [],
parseAssigneeValue: (value: string) => ({
assigneeAgentId: value.startsWith("agent:") ? value.slice("agent:".length) : null,
assigneeUserId: value.startsWith("user:") ? value.slice("user:".length) : null,
}),
}));
vi.mock("./MarkdownEditor", async () => {
const React = await import("react");
return {
MarkdownEditor: React.forwardRef<
{ focus: () => void },
{ value: string; onChange?: (value: string) => void; placeholder?: string }
>(function MarkdownEditorMock({ value, onChange, placeholder }, ref) {
React.useImperativeHandle(ref, () => ({
focus: () => undefined,
}));
return (
<textarea
aria-label={placeholder ?? "Description"}
value={value}
onChange={(event) => onChange?.(event.target.value)}
/>
);
}),
};
});
vi.mock("./InlineEntitySelector", async () => {
const React = await import("react");
return {
InlineEntitySelector: React.forwardRef<
HTMLButtonElement,
{
value: string;
placeholder?: string;
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
}
>(function InlineEntitySelectorMock({ value, placeholder, renderTriggerValue }, ref) {
return (
<button ref={ref} type="button">
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
</button>
);
}),
};
});
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({
children,
showCloseButton: _showCloseButton,
onEscapeKeyDown: _onEscapeKeyDown,
onPointerDownOutside: _onPointerDownOutside,
...props
}: ComponentProps<"div"> & {
showCloseButton?: boolean;
onEscapeKeyDown?: (event: unknown) => void;
onPointerDownOutside?: (event: unknown) => void;
}) => <div {...props}>{children}</div>,
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
<button type={type} onClick={onClick} {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/toggle-switch", () => ({
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
),
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function renderDialog(container: HTMLDivElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<NewIssueDialog />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
describe("NewIssueDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
dialogState.newIssueOpen = true;
dialogState.newIssueDefaults = {};
dialogState.closeNewIssue.mockReset();
toastState.pushToast.mockReset();
mockIssuesApi.create.mockReset();
mockIssuesApi.upsertDocument.mockReset();
mockIssuesApi.uploadAttachment.mockReset();
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
},
]);
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/uploads/asset.png" });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
mockIssuesApi.create.mockResolvedValue({
id: "issue-2",
companyId: "company-1",
identifier: "PAP-2",
});
});
afterEach(() => {
document.body.innerHTML = "";
});
it("shows sub-issue context only when opened from a sub-issue action", async () => {
dialogState.newIssueDefaults = {
parentId: "issue-1",
parentIdentifier: "PAP-1",
parentTitle: "Parent issue",
projectId: "project-1",
goalId: "goal-1",
};
const { root } = renderDialog(container);
await flush();
expect(container.textContent).toContain("New sub-issue");
expect(container.textContent).toContain("Sub-issue of");
expect(container.textContent).toContain("PAP-1");
expect(container.textContent).toContain("Parent issue");
expect(container.textContent).toContain("Create Sub-Issue");
act(() => root.unmount());
dialogState.newIssueDefaults = {};
const rerendered = renderDialog(container);
await flush();
expect(container.textContent).toContain("New issue");
expect(container.textContent).toContain("Create Issue");
expect(container.textContent).not.toContain("Sub-issue of");
act(() => rerendered.root.unmount());
});
it("submits parent and goal context for sub-issues", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
dialogState.newIssueDefaults = {
parentId: "issue-1",
parentIdentifier: "PAP-1",
parentTitle: "Parent issue",
title: "Child issue",
projectId: "project-1",
executionWorkspaceId: "workspace-1",
goalId: "goal-1",
};
const { root } = renderDialog(container);
await flush();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Sub-Issue"));
expect(submitButton).not.toBeUndefined();
expect(submitButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Child issue",
parentId: "issue-1",
goalId: "goal-1",
projectId: "project-1",
executionWorkspaceId: "workspace-1",
}),
);
act(() => root.unmount());
});
it("warns when a sub-issue stops matching the parent workspace", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
{
id: "workspace-2",
name: "Other workspace",
status: "active",
branchName: "feature/pap-2",
cwd: "/tmp/workspace-2",
lastUsedAt: new Date("2026-04-06T16:01:00.000Z"),
},
]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
dialogState.newIssueDefaults = {
parentId: "issue-1",
parentIdentifier: "PAP-1",
parentTitle: "Parent issue",
title: "Child issue",
projectId: "project-1",
executionWorkspaceId: "workspace-1",
parentExecutionWorkspaceLabel: "Parent workspace",
goalId: "goal-1",
};
const { root } = renderDialog(container);
await flush();
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
const selects = Array.from(container.querySelectorAll("select"));
const modeSelect = selects[0] as HTMLSelectElement | undefined;
expect(modeSelect).not.toBeUndefined();
await act(async () => {
modeSelect!.value = "shared_workspace";
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("will no longer use the parent issue workspace");
expect(container.textContent).toContain("Parent workspace");
act(() => root.unmount());
});
});
+63 -3
View File
@@ -46,6 +46,7 @@ import {
Paperclip,
FileText,
Loader2,
ListTree,
X,
} from "lucide-react";
import { cn } from "../lib/utils";
@@ -297,6 +298,11 @@ export function NewIssueDialog() {
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
const isSubIssueMode = Boolean(newIssueDefaults.parentId);
const parentIssueLabel = newIssueDefaults.parentIdentifier
?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : "");
const parentExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId ?? "";
const parentExecutionWorkspaceLabel = newIssueDefaults.parentExecutionWorkspaceLabel ?? parentExecutionWorkspaceId;
// Popover states
const [statusOpen, setStatusOpen] = useState(false);
@@ -510,7 +516,28 @@ export function NewIssueDialog() {
executionWorkspaceDefaultProjectId.current = null;
const draft = loadDraft();
if (newIssueDefaults.title) {
if (newIssueDefaults.parentId) {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
?? defaultProjectWorkspaceIdForProject(defaultProject);
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
? "reuse_existing"
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
setTitle(newIssueDefaults.title ?? "");
setDescription(newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceId);
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceMode);
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
} else if (newIssueDefaults.title) {
setTitle(newIssueDefaults.title);
setDescription(newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
@@ -616,6 +643,7 @@ export function NewIssueDialog() {
}
function handleCompanyChange(companyId: string) {
if (isSubIssueMode) return;
if (companyId === effectiveCompanyId) return;
setDialogCompanyId(companyId);
setAssigneeValue("");
@@ -666,6 +694,8 @@ export function NewIssueDialog() {
priority: priority || "medium",
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
...(newIssueDefaults.goalId ? { goalId: newIssueDefaults.goalId } : {}),
...(projectId ? { projectId } : {}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
@@ -774,6 +804,13 @@ export function NewIssueDialog() {
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const isUsingParentExecutionWorkspace = isSubIssueMode && parentExecutionWorkspaceId
? executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId === parentExecutionWorkspaceId
: false;
const showParentWorkspaceWarning = isSubIssueMode
&& currentProjectSupportsExecutionWorkspace
&& Boolean(parentExecutionWorkspaceId)
&& !isUsingParentExecutionWorkspace;
const assigneeOptionsTitle =
assigneeAdapterType === "claude_local"
? "Claude options"
@@ -908,6 +945,7 @@ export function NewIssueDialog() {
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
!dialogCompany?.brandColor && "bg-muted",
)}
disabled={isSubIssueMode}
style={
dialogCompany?.brandColor
? {
@@ -955,7 +993,7 @@ export function NewIssueDialog() {
</PopoverContent>
</Popover>
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New issue</span>
<span>{isSubIssueMode ? "New sub-issue" : "New issue"}</span>
</div>
<div className="flex items-center gap-1">
<Button
@@ -1119,6 +1157,23 @@ export function NewIssueDialog() {
</div>
</div>
{isSubIssueMode ? (
<div className="px-4 pb-2 shrink-0">
<div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ListTree className="h-3.5 w-3.5 shrink-0" />
<span className="shrink-0">Sub-issue of</span>
<span className="font-medium text-foreground">{parentIssueLabel}</span>
</div>
{newIssueDefaults.parentTitle ? (
<div className="pl-5 text-foreground/80 truncate">
{newIssueDefaults.parentTitle}
</div>
) : null}
</div>
</div>
) : null}
{currentProject && currentProjectSupportsExecutionWorkspace && (
<div className="px-4 py-3 shrink-0 space-y-2">
<div className="space-y-1.5">
@@ -1161,6 +1216,11 @@ export function NewIssueDialog() {
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
</div>
)}
{showParentWorkspaceWarning ? (
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-100">
Warning: this sub-issue will no longer use the parent issue workspace{parentExecutionWorkspaceLabel ? ` (${parentExecutionWorkspaceLabel})` : ""}.
</div>
) : null}
</div>
</div>
)}
@@ -1455,7 +1515,7 @@ export function NewIssueDialog() {
>
<span className="inline-flex items-center justify-center gap-1.5">
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
<span>{createIssue.isPending ? "Creating..." : isSubIssueMode ? "Create Sub-Issue" : "Create Issue"}</span>
</span>
</Button>
</div>
+7 -1
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
import { usePanel } from "../context/PanelContext";
import { cn } from "../lib/utils";
function resolveScrollTarget() {
const mainContent = document.getElementById("main-content");
@@ -33,6 +35,7 @@ function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
*/
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
const { panelVisible, panelContent } = usePanel();
useEffect(() => {
const check = () => {
@@ -70,7 +73,10 @@ export function ScrollToBottom() {
return (
<button
onClick={scroll}
className="fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors md:bottom-6"
className={cn(
"fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-[background-color,right] duration-200 md:bottom-6",
panelVisible && panelContent && "md:right-[calc(320px+1.5rem)]",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
+8
View File
@@ -4,6 +4,14 @@ interface NewIssueDefaults {
status?: string;
priority?: string;
projectId?: string;
projectWorkspaceId?: string;
goalId?: string;
parentId?: string;
parentIdentifier?: string;
parentTitle?: string;
executionWorkspaceId?: string;
executionWorkspaceMode?: string;
parentExecutionWorkspaceLabel?: string;
assigneeAgentId?: string;
assigneeUserId?: string;
title?: string;
+2 -1
View File
@@ -515,13 +515,14 @@ describe("inbox helpers", () => {
});
it("hides the workspace column option unless isolated workspaces are enabled", () => {
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "labels", "updated"]);
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "parent", "labels", "updated"]);
expect(getAvailableInboxIssueColumns(true)).toEqual([
"status",
"id",
"assignee",
"project",
"workspace",
"parent",
"labels",
"updated",
]);
+1 -1
View File
@@ -9,7 +9,7 @@ export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "labels", "updated"] as const;
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
export type InboxWorkItem =
+45 -15
View File
@@ -3,50 +3,80 @@ import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
createIssueDetailPath,
hasLegacyIssueDetailQuery,
readIssueDetailLocationState,
readIssueDetailBreadcrumb,
rememberIssueDetailLocationState,
shouldArmIssueDetailInboxQuickArchive,
} from "./issueDetailBreadcrumb";
const sessionStorageMock = (() => {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
clear: () => {
store.clear();
},
};
})();
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { sessionStorage: sessionStorageMock },
});
describe("issueDetailBreadcrumb", () => {
it("returns clean issue detail paths", () => {
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
});
it("prefers the full breadcrumb from route state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
expect(readIssueDetailBreadcrumb("PAP-465", state, "?from=issues")).toEqual({
label: "Inbox",
href: "/inbox/mine",
});
});
it("falls back to the source query param when route state is unavailable", () => {
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
expect(readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox")).toEqual({
label: "Inbox",
href: "/inbox",
});
});
it("adds the source query param when building an issue detail path", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(createIssueDetailPath("PAP-465", state)).toBe(
"/issues/PAP-465?from=inbox&fromHref=%2Finbox%2Fmine",
);
});
it("reuses the current source query param when state has been dropped", () => {
expect(createIssueDetailPath("PAP-465", null, "?from=issues&fromHref=%2Fissues%3Fq%3Dabc")).toBe(
"/issues/PAP-465?from=issues&fromHref=%2Fissues%3Fq%3Dabc",
);
it("can detect legacy query-based breadcrumb links", () => {
expect(hasLegacyIssueDetailQuery("?from=inbox&fromHref=%2Finbox%2Fmine")).toBe(true);
expect(hasLegacyIssueDetailQuery("?q=test")).toBe(false);
});
it("restores the exact breadcrumb href from the query fallback", () => {
expect(
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
).toEqual({
label: "Inbox",
href: "/PAP/inbox/unread",
});
});
it("reads hidden breadcrumb context from session storage when route state is unavailable", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
sessionStorageMock.clear();
rememberIssueDetailLocationState("PAP-465", state);
expect(
readIssueDetailLocationState("PAP-465", null),
).toEqual({
issueDetailBreadcrumb: { label: "Inbox", href: "/inbox/mine" },
issueDetailSource: "inbox",
issueDetailInboxQuickArchiveArmed: false,
});
});
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
+94 -19
View File
@@ -13,6 +13,7 @@ type IssueDetailLocationState = {
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
const ISSUE_DETAIL_STORAGE_KEY_PREFIX = "paperclip:issue-detail-breadcrumb:";
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false;
@@ -44,6 +45,17 @@ function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null
return href && href.startsWith("/") ? href : null;
}
function inferIssueDetailSource(
state: Partial<IssueDetailLocationState> | null,
breadcrumb: IssueDetailBreadcrumb | null,
): IssueDetailSource | null {
if (isIssueDetailSource(state?.issueDetailSource)) return state.issueDetailSource;
if (!breadcrumb) return null;
if (breadcrumb.label === "Inbox" || breadcrumb.href.includes("/inbox")) return "inbox";
if (breadcrumb.label === "Issues" || breadcrumb.href.includes("/issues")) return "issues";
return null;
}
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
return { label: "Issues", href: "/issues" };
@@ -71,34 +83,97 @@ export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLoca
};
}
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
const breadcrumb =
(typeof state === "object" && state !== null
? (state as IssueDetailLocationState).issueDetailBreadcrumb
: null);
const breadcrumbHref =
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
readIssueDetailBreadcrumbHrefFromSearch(search);
if (!source) return `/issues/${issuePathId}`;
const params = new URLSearchParams();
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
return `/issues/${issuePathId}?${params.toString()}`;
function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLocationState | null {
if (typeof window === "undefined" || !window.sessionStorage) return null;
const raw = window.sessionStorage.getItem(`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<IssueDetailLocationState>;
const breadcrumb = isIssueDetailBreadcrumb(parsed.issueDetailBreadcrumb)
? parsed.issueDetailBreadcrumb
: null;
const source = inferIssueDetailSource(parsed, breadcrumb);
if (!breadcrumb || !source) return null;
return {
issueDetailBreadcrumb: breadcrumb,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
};
} catch {
return null;
}
}
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
function normalizeIssueDetailLocationState(
state: unknown,
search?: string,
): IssueDetailLocationState | null {
if (typeof state === "object" && state !== null) {
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
if (isIssueDetailBreadcrumb(candidate)) return candidate;
if (isIssueDetailBreadcrumb(candidate)) {
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
if (!source) return null;
return {
issueDetailBreadcrumb: candidate,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed:
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
};
}
}
const source = readIssueDetailSourceFromSearch(search);
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
if (!source) return null;
const fallback = breadcrumbForSource(source);
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
return href ? { ...fallback, href } : fallback;
return {
issueDetailBreadcrumb: href ? { ...breadcrumbForSource(source), href } : breadcrumbForSource(source),
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: false,
};
}
export function rememberIssueDetailLocationState(issuePathId: string, state: unknown, search?: string): void {
if (typeof window === "undefined" || !window.sessionStorage) return;
const normalized = normalizeIssueDetailLocationState(state, search);
if (!normalized) return;
window.sessionStorage.setItem(
`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`,
JSON.stringify(normalized),
);
}
export function createIssueDetailPath(issuePathId: string): string {
return `/issues/${issuePathId}`;
}
export function hasLegacyIssueDetailQuery(search?: string): boolean {
if (!search) return false;
const params = new URLSearchParams(search);
return params.has(ISSUE_DETAIL_SOURCE_QUERY_PARAM) || params.has(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
}
export function readIssueDetailLocationState(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailLocationState | null {
const normalized = normalizeIssueDetailLocationState(state, search);
if (normalized) return normalized;
if (!issuePathId) return null;
return readStoredIssueDetailLocationState(issuePathId);
}
export function readIssueDetailBreadcrumb(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailBreadcrumb | null {
return readIssueDetailLocationState(issuePathId, state, search)?.issueDetailBreadcrumb ?? null;
}
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
@@ -212,4 +212,18 @@ describe("optimistic issue comments", () => {
}),
).toBe(false);
});
it("does not mark comments from the active run agent as queued", () => {
expect(
isQueuedIssueComment({
comment: {
createdAt: new Date("2026-03-28T16:20:05.000Z"),
authorAgentId: "agent-1",
},
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
activeRunAgentId: "agent-1",
runId: null,
}),
).toBe(false);
});
});
+8 -1
View File
@@ -59,13 +59,20 @@ export function createOptimisticIssueComment(params: {
}
export function isQueuedIssueComment(params: {
comment: Pick<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
comment: Pick<IssueTimelineComment, "createdAt"> &
Partial<Pick<OptimisticIssueComment, "clientStatus">> & {
authorAgentId?: string | null;
};
activeRunStartedAt?: Date | string | null;
activeRunAgentId?: string | null;
runId?: string | null;
interruptedRunId?: string | null;
}) {
if (params.runId) return false;
if (params.interruptedRunId) return false;
if (params.comment.authorAgentId && params.activeRunAgentId && params.comment.authorAgentId === params.activeRunAgentId) {
return false;
}
if (params.comment.clientStatus === "queued") return true;
if (!params.activeRunStartedAt) return false;
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
+2 -2
View File
@@ -30,8 +30,8 @@ export const queryKeys = {
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,
search: (companyId: string, q: string, projectId?: string) =>
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
search: (companyId: string, q: string, projectId?: string, limit?: number) =>
["issues", companyId, "search", q, projectId ?? "__all-projects__", limit ?? "__no-limit__"] as const,
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
+3
View File
@@ -123,6 +123,9 @@ export function Approvals() {
onReject={() => rejectMutation.mutate(approval.id)}
detailLink={`/approvals/${approval.id}`}
isPending={approveMutation.isPending || rejectMutation.isPending}
pendingAction={
approveMutation.isPending ? "approve" : rejectMutation.isPending ? "reject" : null
}
/>
))}
</div>
+32 -23
View File
@@ -378,7 +378,7 @@ export function ExecutionWorkspaceDetail() {
return (
<>
<div className="mx-auto max-w-5xl space-y-6">
<div className="mx-auto max-w-5xl space-y-4 overflow-hidden sm:space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
@@ -393,19 +393,20 @@ export function ExecutionWorkspaceDetail() {
</StatusPill>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
<div className="min-w-0 space-y-4 sm:space-y-6">
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
<span className="hidden sm:inline"> These settings stay
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
and runtime-service behavior in sync with the actual workspace being reused.
and runtime-service behavior in sync with the actual workspace being reused.</span>
</p>
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
@@ -482,7 +483,7 @@ export function ExecutionWorkspaceDetail() {
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
value={form.provisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree.sh"
@@ -490,7 +491,7 @@ export function ExecutionWorkspaceDetail() {
</Field>
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
value={form.teardownCommand}
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
placeholder="bash ./scripts/teardown-worktree.sh"
@@ -501,7 +502,7 @@ export function ExecutionWorkspaceDetail() {
<div className="mt-4 grid gap-4">
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
<textarea
className="min-h-24 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
className="min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true"
@@ -546,14 +547,22 @@ export function ExecutionWorkspaceDetail() {
id="inherit-runtime-config"
type="checkbox"
checked={form.inheritRuntime}
onChange={(event) =>
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
}
onChange={(event) => {
const checked = event.target.checked;
setForm((current) => {
if (!current) return current;
// When unchecking "inherit" and the field is empty, copy inherited config as a starting point
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
}
return { ...current, inheritRuntime: checked };
});
}}
/>
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
</div>
<textarea
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
className="min-h-32 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-48"
value={form.workspaceRuntime}
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
disabled={form.inheritRuntime}
@@ -586,8 +595,8 @@ export function ExecutionWorkspaceDetail() {
</div>
</div>
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="min-w-0 space-y-4 sm:space-y-6">
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
<h2 className="text-lg font-semibold">Workspace context</h2>
@@ -632,7 +641,7 @@ export function ExecutionWorkspaceDetail() {
</DetailRow>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
<h2 className="text-lg font-semibold">Concrete location</h2>
@@ -676,7 +685,7 @@ export function ExecutionWorkspaceDetail() {
</DetailRow>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
@@ -755,7 +764,7 @@ export function ExecutionWorkspaceDetail() {
)}
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
@@ -798,7 +807,7 @@ export function ExecutionWorkspaceDetail() {
</div>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
@@ -819,12 +828,12 @@ export function ExecutionWorkspaceDetail() {
: "Failed to load linked issues."}
</p>
) : linkedIssues.length > 0 ? (
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
<div className="-mx-1 flex flex-col gap-3 px-1 pb-1 sm:flex-row sm:overflow-x-auto">
{linkedIssues.map((issue) => (
<Link
key={issue.id}
to={issueUrl(issue)}
className="min-w-72 rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20"
className="rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20 sm:min-w-72"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
+4
View File
@@ -205,6 +205,8 @@ describe("InboxIssueTrailingColumns", () => {
workspaceName={null}
assigneeName={null}
currentUserId={null}
parentIdentifier={null}
parentTitle={null}
/>,
);
});
@@ -229,6 +231,8 @@ describe("InboxIssueTrailingColumns", () => {
workspaceName={null}
assigneeName={null}
currentUserId={null}
parentIdentifier={null}
parentTitle={null}
/>,
);
});
+83 -20
View File
@@ -21,6 +21,7 @@ import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
createIssueDetailPath,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState";
@@ -140,13 +141,14 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"];
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
status: "Status",
id: "ID",
assignee: "Assignee",
project: "Project",
workspace: "Workspace",
parent: "Parent issue",
labels: "Tags",
updated: "Last updated",
};
@@ -156,6 +158,7 @@ const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
assignee: "Assigned agent or board user.",
project: "Linked project pill with its color.",
workspace: "Execution or project workspace used for the issue.",
parent: "Parent issue identifier and title.",
labels: "Issue labels and tags.",
updated: "Latest visible activity time.",
};
@@ -223,8 +226,9 @@ function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
if (column === "project") return "minmax(6.5rem, 8.5rem)";
if (column === "workspace") return "minmax(9rem, 12rem)";
if (column === "parent") return "minmax(5rem, 7rem)";
if (column === "labels") return "minmax(8rem, 10rem)";
return "minmax(6rem, 7rem)";
return "minmax(4rem, 5.5rem)";
})
.join(" ");
}
@@ -237,6 +241,8 @@ export function InboxIssueTrailingColumns({
workspaceName,
assigneeName,
currentUserId,
parentIdentifier,
parentTitle,
}: {
issue: Issue;
columns: InboxIssueColumn[];
@@ -245,6 +251,8 @@ export function InboxIssueTrailingColumns({
workspaceName: string | null;
assigneeName: string | null;
currentUserId: string | null;
parentIdentifier: string | null;
parentTitle: string | null;
}) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
@@ -347,6 +355,22 @@ export function InboxIssueTrailingColumns({
);
}
if (column === "parent") {
if (!issue.parentId) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
{parentIdentifier ? (
<span className="font-mono">{parentIdentifier}</span>
) : (
<span className="italic">Sub-issue</span>
)}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
{activityText}
@@ -1245,30 +1269,53 @@ export function Inbox() {
const archiveIssueMutation = useMutation({
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
onMutate: (id) => {
onMutate: async (id) => {
setActionError(null);
setArchivingIssueIds((prev) => new Set(prev).add(id));
// Cancel in-flight refetches so they don't overwrite our optimistic update
const queryKeys_ = [
queryKeys.issues.listMineByMe(selectedCompanyId!),
queryKeys.issues.listTouchedByMe(selectedCompanyId!),
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
];
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk })));
// Snapshot previous data for rollback
const previousData = queryKeys_.map((qk) => [qk, queryClient.getQueryData(qk)] as const);
// Optimistically remove the issue from all inbox query caches
for (const qk of queryKeys_) {
queryClient.setQueryData(qk, (old: unknown) => {
if (!Array.isArray(old)) return old;
return old.filter((issue: { id: string }) => issue.id !== id);
});
}
return { previousData };
},
onSuccess: () => {
invalidateInboxIssueQueries();
},
onError: (err, id) => {
onError: (err, id, context) => {
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
setArchivingIssueIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
// Restore previous query data on failure
if (context?.previousData) {
for (const [qk, data] of context.previousData) {
queryClient.setQueryData(qk, data);
}
}
},
onSettled: (_data, error, id) => {
if (error) return;
window.setTimeout(() => {
setArchivingIssueIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 500);
// Clean up archiving state and refetch to sync with server
setArchivingIssueIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
invalidateInboxIssueQueries();
},
});
@@ -1498,7 +1545,8 @@ export function Inbox() {
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
act.navigate(createIssueDetailPath(pathId, detailState), { state: detailState });
rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
@@ -1566,7 +1614,19 @@ export function Inbox() {
const canMarkAllRead = unreadIssueIds.length > 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<div className="space-y-2">
{/* Search — full-width row on mobile, inline on desktop */}
<div className="relative sm:hidden">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-full pl-8 text-xs"
/>
</div>
<div className="flex items-center justify-between gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
@@ -1585,14 +1645,14 @@ export function Inbox() {
</Tabs>
<div className="flex items-center gap-2">
<div className="relative">
<div className="relative hidden sm:block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-[180px] pl-8 text-xs sm:w-[220px]"
className="h-8 w-[220px] pl-8 text-xs"
/>
</div>
<DropdownMenu>
@@ -1601,7 +1661,7 @@ export function Inbox() {
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground"
className="hidden h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground sm:inline-flex"
>
<Columns3 className="mr-1 h-3.5 w-3.5" />
Show / hide columns
@@ -1685,6 +1745,7 @@ export function Inbox() {
</>
)}
</div>
</div>
</div>
{tab === "all" && (
@@ -1941,6 +2002,8 @@ export function Inbox() {
})}
assigneeName={agentName(issue.assigneeAgentId)}
currentUserId={currentUserId}
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
/>
) : undefined
}
+320 -139
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { approvalsApi } from "../api/approvals";
import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
@@ -10,6 +11,7 @@ import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -17,8 +19,11 @@ import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../li
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
import {
hasLegacyIssueDetailQuery,
createIssueDetailPath,
readIssueDetailLocationState,
readIssueDetailBreadcrumb,
rememberIssueDetailLocationState,
shouldArmIssueDetailInboxQuickArchive,
} from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
@@ -33,6 +38,7 @@ import {
} from "../lib/optimistic-issue-comments";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
@@ -44,21 +50,18 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Activity as ActivityIcon,
Check,
ChevronDown,
ChevronRight,
Copy,
EyeOff,
@@ -287,6 +290,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId, selectedCompany } = useCompany();
const { openNewIssue } = useDialog();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@@ -297,9 +301,11 @@ export function IssueDetail() {
const [copied, setCopied] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [detailTab, setDetailTab] = useState("comments");
const [secondaryOpen, setSecondaryOpen] = useState({
approvals: false,
});
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
approvalId: string;
action: "approve" | "reject";
} | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false);
@@ -375,9 +381,13 @@ export function IssueDetail() {
),
[activeRun, liveRuns],
);
const resolvedIssueDetailState = useMemo(
() => readIssueDetailLocationState(issueId, location.state, location.search),
[issueId, location.state, location.search],
);
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
[location.state, location.search],
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
[issueId, location.state, location.search],
);
// Filter out runs already shown by the live widget to avoid duplication
@@ -484,6 +494,45 @@ export function IssueDetail() {
.filter((i) => i.parentId === issue.id)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}, [allIssues, issue]);
const childIssuesPanelKey = useMemo(
() => childIssues.map((child) => `${child.id}:${String(child.updatedAt)}`).join("|"),
[childIssues],
);
const issuePanelKey = issue
? `${issue.id}:${String(issue.updatedAt)}:${childIssuesPanelKey}`
: "";
const openNewSubIssue = useCallback(() => {
if (!issue) return;
openNewIssue({
parentId: issue.id,
parentIdentifier: issue.identifier ?? undefined,
parentTitle: issue.title,
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
goalId: issue.goalId ?? undefined,
executionWorkspaceId: issue.executionWorkspaceId ?? undefined,
executionWorkspaceMode: issue.executionWorkspaceId ? "reuse_existing" : issue.executionWorkspacePreference ?? undefined,
parentExecutionWorkspaceLabel:
issue.currentExecutionWorkspace?.name
?? issue.currentExecutionWorkspace?.branchName
?? issue.currentExecutionWorkspace?.cwd
?? issue.executionWorkspaceId
?? undefined,
});
}, [
issue?.currentExecutionWorkspace?.branchName,
issue?.currentExecutionWorkspace?.cwd,
issue?.currentExecutionWorkspace?.name,
issue?.executionWorkspaceId,
issue?.executionWorkspacePreference,
issue?.goalId,
issue?.id,
issue?.identifier,
issue?.projectId,
issue?.projectWorkspaceId,
issue?.title,
openNewIssue,
]);
const commentReassignOptions = useMemo(() => {
const options: Array<{ id: string; label: string; searchText?: string }> = [];
@@ -546,6 +595,7 @@ export function IssueDetail() {
isQueuedIssueComment({
comment: nextComment,
activeRunStartedAt,
activeRunAgentId: runningIssueRun?.agentId ?? null,
runId: meta?.runId ?? nextComment.runId ?? null,
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
})
@@ -650,6 +700,42 @@ export function IssueDetail() {
invalidateIssue();
},
});
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
updateIssue.mutate(data);
}, [updateIssue.mutate]);
const approvalDecision = useMutation({
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
if (action === "approve") {
return approvalsApi.approve(approvalId);
}
return approvalsApi.reject(approvalId);
},
onMutate: ({ approvalId, action }) => {
setPendingApprovalAction({ approvalId, action });
},
onSuccess: (_approval, variables) => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) });
if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) });
}
pushToast({
title: variables.action === "approve" ? "Approval approved" : "Approval rejected",
tone: "success",
});
},
onError: (err, variables) => {
pushToast({
title: variables.action === "approve" ? "Approval failed" : "Rejection failed",
body: err instanceof Error ? err.message : "Unable to update approval",
tone: "error",
});
},
onSettled: () => {
setPendingApprovalAction(null);
},
});
const addComment = useMutation({
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
@@ -967,13 +1053,24 @@ export function IssueDetail() {
// Redirect to identifier-based URL if navigated via UUID
useEffect(() => {
const nextState = resolvedIssueDetailState ?? location.state;
if (issue?.identifier && issueId !== issue.identifier) {
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
rememberIssueDetailLocationState(issue.identifier, nextState, location.search);
navigate(createIssueDetailPath(issue.identifier), {
replace: true,
state: location.state,
state: nextState,
});
return;
}
if (issueId && hasLegacyIssueDetailQuery(location.search)) {
rememberIssueDetailLocationState(issueId, nextState, location.search);
navigate(createIssueDetailPath(issueId), {
replace: true,
state: nextState,
});
}
}, [issue, issueId, navigate, location.state, location.search]);
}, [issue, issueId, navigate, location.state, location.search, resolvedIssueDetailState]);
useEffect(() => {
if (!issue?.id) return;
@@ -983,13 +1080,20 @@ export function IssueDetail() {
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (issue) {
openPanel(
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
);
if (!issue) {
closePanel();
return;
}
openPanel(
<IssueProperties
issue={issue}
childIssues={childIssues}
onAddSubIssue={openNewSubIssue}
onUpdate={handleIssuePropertiesUpdate}
/>
);
return () => closePanel();
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
const inboxQuickArchiveArmedRef = useRef(false);
const canQuickArchiveFromInbox =
@@ -1115,13 +1219,13 @@ export function IssueDetail() {
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
const attachmentList = attachments ?? [];
const imageAttachments = attachmentList.filter(isImageAttachment);
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
const hasAttachments = attachmentList.length > 0;
const attachmentUploadButton = (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
className="hidden"
onChange={handleFilePicked}
multiple
@@ -1156,8 +1260,14 @@ export function IssueDetail() {
<span key={ancestor.id} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
<Link
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
state={location.state}
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
state={resolvedIssueDetailState ?? location.state}
onClickCapture={() =>
rememberIssueDetailLocationState(
ancestor.identifier ?? ancestor.id,
resolvedIssueDetailState ?? location.state,
location.search,
)}
className="hover:text-foreground transition-colors truncate max-w-[200px]"
title={ancestor.title}
>
@@ -1330,6 +1440,9 @@ export function IssueDetail() {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onDropFile={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
/>
</div>
@@ -1374,6 +1487,50 @@ export function IssueDetail() {
missingBehavior="placeholder"
/>
{childIssues.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3>
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
<ListTree className="h-3.5 w-3.5 mr-1.5" />
<span className="hidden sm:inline">Add sub-issue</span>
<span className="sm:hidden">Sub-issue</span>
</Button>
</div>
<div className="border border-border rounded-lg divide-y divide-border">
{childIssues.map((child) => (
<Link
key={child.id}
to={createIssueDetailPath(child.identifier ?? child.id)}
state={resolvedIssueDetailState ?? location.state}
onClickCapture={() =>
rememberIssueDetailLocationState(
child.identifier ?? child.id,
resolvedIssueDetailState ?? location.state,
location.search,
)}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={child.status} />
<PriorityIcon priority={child.priority} />
<span className="font-mono text-muted-foreground shrink-0">
{child.identifier ?? child.id.slice(0, 8)}
</span>
<span className="truncate">{child.title}</span>
</div>
{child.assigneeAgentId && (() => {
const name = agentMap.get(child.assigneeAgentId)?.name;
return name
? <Identity name={name} size="sm" />
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
})()}
</Link>
))}
</div>
</div>
)}
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
@@ -1395,7 +1552,18 @@ export function IssueDetail() {
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
extraActions={
<>
{!hasAttachments && attachmentUploadButton}
{childIssues.length === 0 && (
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
<ListTree className="h-3.5 w-3.5 mr-1.5" />
<span className="hidden sm:inline">Add sub-issue</span>
<span className="sm:hidden">Sub-issue</span>
</Button>
)}
</>
}
/>
{hasAttachments ? (
@@ -1426,53 +1594,105 @@ export function IssueDetail() {
<p className="text-xs text-destructive">{attachmentError}</p>
)}
<div className="space-y-2">
{attachmentList.map((attachment) => (
<div key={attachment.id} className="border border-border rounded-md p-2">
<div className="flex items-center justify-between gap-2">
<a
href={attachment.contentPath}
target="_blank"
rel="noreferrer"
className="text-xs hover:underline truncate"
title={attachment.originalFilename ?? attachment.id}
>
{attachment.originalFilename ?? attachment.id}
</a>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteAttachment.mutate(attachment.id)}
disabled={deleteAttachment.isPending}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{imageAttachments.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="group relative aspect-square rounded-lg overflow-hidden border border-border bg-accent/10 cursor-pointer"
onClick={() => {
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
setGalleryIndex(idx >= 0 ? idx : 0);
setGalleryOpen(true);
}}
>
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="h-full w-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors" />
{confirmDeleteId === attachment.id ? (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs text-white font-medium">Delete?</p>
<div className="flex gap-1.5">
<button
type="button"
className="rounded bg-destructive px-2 py-0.5 text-xs text-white hover:bg-destructive/80"
onClick={(e) => {
e.stopPropagation();
deleteAttachment.mutate(attachment.id);
setConfirmDeleteId(null);
}}
disabled={deleteAttachment.isPending}
>
Yes
</button>
<button
type="button"
className="rounded bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(null);
}}
>
No
</button>
</div>
</div>
) : (
<button
type="button"
className="absolute top-1.5 right-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(attachment.id);
}}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<p className="text-[11px] text-muted-foreground">
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
</p>
{isImageAttachment(attachment) && (
<button
type="button"
className="block w-full text-left"
onClick={() => {
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
setGalleryIndex(idx >= 0 ? idx : 0);
setGalleryOpen(true);
}}
>
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10 cursor-pointer hover:opacity-80 transition-opacity"
loading="lazy"
/>
</button>
)}
</div>
))}
</div>
))}
</div>
)}
{nonImageAttachments.length > 0 && (
<div className="space-y-2">
{nonImageAttachments.map((attachment) => (
<div key={attachment.id} className="border border-border rounded-md p-2">
<div className="flex items-center justify-between gap-2">
<a
href={attachment.contentPath}
target="_blank"
rel="noreferrer"
className="text-xs hover:underline truncate"
title={attachment.originalFilename ?? attachment.id}
>
{attachment.originalFilename ?? attachment.id}
</a>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteAttachment.mutate(attachment.id)}
disabled={deleteAttachment.isPending}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-muted-foreground">
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
</p>
</div>
))}
</div>
)}
</div>
) : null}
@@ -1497,10 +1717,6 @@ export function IssueDetail() {
<MessageSquare className="h-3.5 w-3.5" />
Comments
</TabsTrigger>
<TabsTrigger value="subissues" className="gap-1.5">
<ListTree className="h-3.5 w-3.5" />
Sub-issues
</TabsTrigger>
<TabsTrigger value="activity" className="gap-1.5">
<ActivityIcon className="h-3.5 w-3.5" />
Activity
@@ -1516,6 +1732,7 @@ export function IssueDetail() {
<CommentThread
comments={timelineComments}
queuedComments={queuedComments}
linkedApprovals={linkedApprovals}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
@@ -1523,6 +1740,13 @@ export function IssueDetail() {
timelineEvents={timelineEvents}
companyId={issue.companyId}
projectId={issue.projectId}
onApproveApproval={async (approvalId) => {
await approvalDecision.mutateAsync({ approvalId, action: "approve" });
}}
onRejectApproval={async (approvalId) => {
await approvalDecision.mutateAsync({ approvalId, action: "reject" });
}}
pendingApprovalAction={pendingApprovalAction}
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
@@ -1565,39 +1789,27 @@ export function IssueDetail() {
/>
</TabsContent>
<TabsContent value="subissues">
{childIssues.length === 0 ? (
<p className="text-xs text-muted-foreground">No sub-issues.</p>
) : (
<div className="border border-border rounded-lg divide-y divide-border">
{childIssues.map((child) => (
<Link
key={child.id}
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
state={location.state}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={child.status} />
<PriorityIcon priority={child.priority} />
<span className="font-mono text-muted-foreground shrink-0">
{child.identifier ?? child.id.slice(0, 8)}
</span>
<span className="truncate">{child.title}</span>
</div>
{child.assigneeAgentId && (() => {
const name = agentMap.get(child.assigneeAgentId)?.name;
return name
? <Identity name={name} size="sm" />
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
})()}
</Link>
<TabsContent value="activity">
{linkedApprovals && linkedApprovals.length > 0 && (
<div className="mb-3 space-y-3">
{linkedApprovals.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })}
onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })}
detailLink={`/approvals/${approval.id}`}
isPending={pendingApprovalAction?.approvalId === approval.id}
pendingAction={
pendingApprovalAction?.approvalId === approval.id
? pendingApprovalAction.action
: null
}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value="activity">
{linkedRuns && linkedRuns.length > 0 && (
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
@@ -1653,43 +1865,6 @@ export function IssueDetail() {
)}
</Tabs>
{linkedApprovals && linkedApprovals.length > 0 && (
<Collapsible
open={secondaryOpen.approvals}
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, approvals: open }))}
className="rounded-lg border border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
<span className="text-sm font-medium text-muted-foreground">
Linked Approvals ({linkedApprovals.length})
</span>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.approvals && "rotate-180")}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border divide-y divide-border">
{linkedApprovals.map((approval) => (
<Link
key={approval.id}
to={`/approvals/${approval.id}`}
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2">
<StatusBadge status={approval.status} />
<span className="font-medium">
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span>
</div>
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span>
</Link>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Mobile properties drawer */}
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
@@ -1699,7 +1874,13 @@ export function IssueDetail() {
</SheetHeader>
<ScrollArea className="flex-1 overflow-y-auto">
<div className="px-4 pb-4">
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
<IssueProperties
issue={issue}
childIssues={childIssues}
onAddSubIssue={openNewSubIssue}
onUpdate={(data) => updateIssue.mutate(data)}
inline
/>
</div>
</ScrollArea>
</SheetContent>