mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
Merge pull request #3000 from paperclipai/pap-1167-app-ui-bundle
Improve issue detail workflows, approvals, and board UX
This commit is contained in:
commit
cae7cda463
52 changed files with 15996 additions and 378 deletions
209
doc/plans/2026-04-06-subissue-creation-on-issue-detail.md
Normal file
209
doc/plans/2026-04-06-subissue-creation-on-issue-detail.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -45,6 +45,11 @@ type TableDefinition = {
|
||||||
tablename: string;
|
tablename: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExtensionDefinition = {
|
||||||
|
extension_name: string;
|
||||||
|
schema_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
const DRIZZLE_SCHEMA = "drizzle";
|
const DRIZZLE_SCHEMA = "drizzle";
|
||||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||||
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
||||||
|
|
@ -376,6 +381,25 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||||
emit("");
|
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) {
|
if (sequences.length > 0) {
|
||||||
emit("-- Sequences");
|
emit("-- Sequences");
|
||||||
for (const seq of sequences) {
|
for (const seq of sequences) {
|
||||||
|
|
|
||||||
5
packages/db/src/migrations/0051_young_korg.sql
Normal file
5
packages/db/src/migrations/0051_young_korg.sql
Normal file
|
|
@ -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);
|
||||||
12836
packages/db/src/migrations/meta/0051_snapshot.json
Normal file
12836
packages/db/src/migrations/meta/0051_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -358,6 +358,13 @@
|
||||||
"when": 1775487782768,
|
"when": 1775487782768,
|
||||||
"tag": "0050_stiff_luckman",
|
"tag": "0050_stiff_luckman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 51,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775524651831,
|
||||||
|
"tag": "0051_young_korg",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,6 @@ export const issueComments = pgTable(
|
||||||
table.issueId,
|
table.issueId,
|
||||||
table.createdAt,
|
table.createdAt,
|
||||||
),
|
),
|
||||||
|
bodySearchIdx: index("issue_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ export const issues = pgTable(
|
||||||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
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")
|
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
|
||||||
.on(table.companyId, table.originKind, table.originId)
|
.on(table.companyId, table.originKind, table.originId)
|
||||||
.where(
|
.where(
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,12 @@ export const PROJECT_COLORS = [
|
||||||
"#3b82f6", // blue
|
"#3b82f6", // blue
|
||||||
] as const;
|
] 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 type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
||||||
|
|
||||||
export const APPROVAL_STATUSES = [
|
export const APPROVAL_STATUSES = [
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,24 @@ function createApp() {
|
||||||
return app;
|
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", () => {
|
describe("approval routes idempotent retries", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -107,4 +125,56 @@ describe("approval routes idempotent retries", () => {
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
parseAllowedTypes,
|
|
||||||
matchesContentType,
|
|
||||||
DEFAULT_ALLOWED_TYPES,
|
DEFAULT_ALLOWED_TYPES,
|
||||||
|
INLINE_ATTACHMENT_TYPES,
|
||||||
|
isInlineAttachmentContentType,
|
||||||
|
matchesContentType,
|
||||||
|
normalizeContentType,
|
||||||
|
parseAllowedTypes,
|
||||||
} from "../attachment-types.js";
|
} from "../attachment-types.js";
|
||||||
|
|
||||||
describe("parseAllowedTypes", () => {
|
describe("parseAllowedTypes", () => {
|
||||||
|
|
@ -95,3 +98,28 @@ describe("matchesContentType", () => {
|
||||||
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
175
server/src/__tests__/issue-attachment-routes.test.ts
Normal file
175
server/src/__tests__/issue-attachment-routes.test.ts
Normal file
|
|
@ -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]);
|
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 () => {
|
it("accepts issue identifiers through getById", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const issueId = randomUUID();
|
const issueId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
|
||||||
|
|
||||||
|
const unknownHostname = "blocked-host.invalid";
|
||||||
|
|
||||||
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
|
|
@ -42,15 +44,15 @@ describe("privateHostnameGuard", () => {
|
||||||
|
|
||||||
it("blocks unknown hostnames with remediation command", async () => {
|
it("blocks unknown hostnames with remediation command", async () => {
|
||||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
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.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 () => {
|
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
|
||||||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
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.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);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* Shared attachment content-type configuration.
|
* 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
|
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
||||||
* comma-separated list of MIME types or wildcard patterns to expand the
|
* comma-separated list of MIME types or wildcard patterns to expand the
|
||||||
* allowed set.
|
* allowed set for routes that use this allowlist.
|
||||||
*
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
||||||
|
|
@ -29,6 +29,17 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||||
"text/html",
|
"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.
|
* 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.
|
* 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 ----------
|
// ---------- Module-level singletons read once at startup ----------
|
||||||
|
|
||||||
const allowedPatterns: string[] = parseAllowedTypes(
|
const allowedPatterns: string[] = parseAllowedTypes(
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,12 @@ import { logger } from "../middleware/logger.js";
|
||||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.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";
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||||
|
|
||||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||||
|
|
@ -341,6 +346,9 @@ export function issueRoutes(
|
||||||
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
||||||
? req.actor.userId
|
? req.actor.userId
|
||||||
: unreadForUserFilterRaw;
|
: 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")) {
|
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
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" });
|
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
||||||
return;
|
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, {
|
const result = await svc.list(companyId, {
|
||||||
status: req.query.status as string | undefined,
|
status: req.query.status as string | undefined,
|
||||||
|
|
@ -376,6 +388,7 @@ export function issueRoutes(
|
||||||
includeRoutineExecutions:
|
includeRoutineExecutions:
|
||||||
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
|
limit,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
@ -2108,11 +2121,7 @@ export function issueRoutes(
|
||||||
res.status(400).json({ error: "Missing file field 'file'" });
|
res.status(400).json({ error: "Missing file field 'file'" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contentType = (file.mimetype || "").toLowerCase();
|
const contentType = normalizeContentType(file.mimetype);
|
||||||
if (!isAllowedContentType(contentType)) {
|
|
||||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.buffer.length <= 0) {
|
if (file.buffer.length <= 0) {
|
||||||
res.status(422).json({ error: "Attachment is empty" });
|
res.status(422).json({ error: "Attachment is empty" });
|
||||||
return;
|
return;
|
||||||
|
|
@ -2176,11 +2185,17 @@ export function issueRoutes(
|
||||||
assertCompanyAccess(req, attachment.companyId);
|
assertCompanyAccess(req, attachment.companyId);
|
||||||
|
|
||||||
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
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("Content-Length", String(attachment.byteSize || object.contentLength || 0));
|
||||||
res.setHeader("Cache-Control", "private, max-age=60");
|
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";
|
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) => {
|
object.stream.on("error", (err) => {
|
||||||
next(err);
|
next(err);
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export interface IssueFilters {
|
||||||
originId?: string;
|
originId?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IssueRow = typeof issues.$inferSelect;
|
type IssueRow = typeof issues.$inferSelect;
|
||||||
|
|
@ -911,6 +912,9 @@ export function issueService(db: Db) {
|
||||||
return {
|
return {
|
||||||
list: async (companyId: string, filters?: IssueFilters) => {
|
list: async (companyId: string, filters?: IssueFilters) => {
|
||||||
const conditions = [eq(issues.companyId, companyId)];
|
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 touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||||
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
|
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
|
||||||
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
||||||
|
|
@ -999,7 +1003,7 @@ export function issueService(db: Db) {
|
||||||
END
|
END
|
||||||
`;
|
`;
|
||||||
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||||
const rows = await db
|
const baseQuery = db
|
||||||
.select()
|
.select()
|
||||||
.from(issues)
|
.from(issues)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
|
|
@ -1009,6 +1013,7 @@ export function issueService(db: Db) {
|
||||||
desc(canonicalLastActivityAt),
|
desc(canonicalLastActivityAt),
|
||||||
desc(issues.updatedAt),
|
desc(issues.updatedAt),
|
||||||
);
|
);
|
||||||
|
const rows = limit === undefined ? await baseQuery : await baseQuery.limit(limit);
|
||||||
const withLabels = await withIssueLabels(db, rows);
|
const withLabels = await withIssueLabels(db, rows);
|
||||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||||
const withRuns = withActiveRuns(withLabels, runMap);
|
const withRuns = withActiveRuns(withLabels, runMap);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
## 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:
|
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` |
|
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||||
| Release task | `POST /api/issues/:issueId/release` |
|
| Release task | `POST /api/issues/:issueId/release` |
|
||||||
| List agents | `GET /api/companies/:companyId/agents` |
|
| List agents | `GET /api/companies/:companyId/agents` |
|
||||||
|
| Create approval | `POST /api/companies/:companyId/approvals` |
|
||||||
| List company skills | `GET /api/companies/:companyId/skills` |
|
| List company skills | `GET /api/companies/:companyId/skills` |
|
||||||
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
||||||
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lexical/link": "0.35.0",
|
"@lexical/link": "0.35.0",
|
||||||
"lexical": "0.35.0",
|
|
||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
|
@ -41,13 +40,14 @@
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
"hermes-paperclip-adapter": "^0.2.0",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
|
"lexical": "0.35.0",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
"mermaid": "^11.12.0",
|
"mermaid": "^11.12.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export const issuesApi = {
|
||||||
originId?: string;
|
originId?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -53,6 +54,7 @@ export const issuesApi = {
|
||||||
if (filters?.originId) params.set("originId", filters.originId);
|
if (filters?.originId) params.set("originId", filters.originId);
|
||||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||||
if (filters?.q) params.set("q", filters.q);
|
if (filters?.q) params.set("q", filters.q);
|
||||||
|
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||||
import { Link } from "@/lib/router";
|
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 { Identity } from "./Identity";
|
||||||
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
import {
|
||||||
|
approvalSubject,
|
||||||
|
typeIcon,
|
||||||
|
defaultTypeIcon,
|
||||||
|
ApprovalPayloadRenderer,
|
||||||
|
typeLabel,
|
||||||
|
} from "./ApprovalPayload";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import type { Approval, Agent } from "@paperclipai/shared";
|
import type { Approval, Agent } from "@paperclipai/shared";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function statusIcon(status: string) {
|
function statusIcon(status: string) {
|
||||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
|
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,
|
onReject,
|
||||||
onOpen,
|
onOpen,
|
||||||
detailLink,
|
detailLink,
|
||||||
isPending,
|
isPending = false,
|
||||||
|
pendingAction = null,
|
||||||
}: {
|
}: {
|
||||||
approval: Approval;
|
approval: Approval;
|
||||||
requesterAgent: Agent | null;
|
requesterAgent: Agent | null;
|
||||||
onApprove: () => void;
|
onApprove?: () => void;
|
||||||
onReject: () => void;
|
onReject?: () => void;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
detailLink?: string;
|
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 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 =
|
const showResolutionButtons =
|
||||||
|
Boolean(onApprove && onReject) &&
|
||||||
approval.type !== "budget_override_required" &&
|
approval.type !== "budget_override_required" &&
|
||||||
(approval.status === "pending" || approval.status === "revision_requested");
|
(approval.status === "pending" || approval.status === "revision_requested");
|
||||||
|
const hasFooter = showResolutionButtons || Boolean(detailLink || onOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg p-4 space-y-0">
|
<div className="rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||||
{/* Header */}
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-3">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/80">
|
||||||
<div className="flex items-center gap-2">
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="font-medium text-sm">{label}</span>
|
</div>
|
||||||
{requesterAgent && (
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
requested by <Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
<Badge
|
||||||
</span>
|
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>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="shrink-0">
|
||||||
{statusIcon(approval.status)}
|
<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">
|
||||||
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
|
{statusIcon(approval.status)}
|
||||||
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
|
<span className="capitalize">{approval.status.replace(/_/g, " ")}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payload */}
|
<div className="mt-4 border-t border-border/60 pt-4">
|
||||||
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
<ApprovalPayloadRenderer
|
||||||
|
type={approval.type}
|
||||||
|
payload={approval.payload}
|
||||||
|
hidePrimaryTitle={Boolean(subject)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Decision note */}
|
|
||||||
{approval.decisionNote && (
|
{approval.decisionNote && (
|
||||||
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
|
<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">
|
||||||
Note: {approval.decisionNote}
|
<span className="font-medium text-foreground">Decision note.</span> {approval.decisionNote}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{hasFooter ? (
|
||||||
{showResolutionButtons && (
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
|
||||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
{showResolutionButtons && (
|
||||||
size="sm"
|
<>
|
||||||
className="bg-green-700 hover:bg-green-600 text-white"
|
<Button
|
||||||
onClick={onApprove}
|
size="sm"
|
||||||
disabled={isPending}
|
className="bg-green-700 hover:bg-green-600 text-white"
|
||||||
>
|
onClick={onApprove}
|
||||||
Approve
|
disabled={isPending}
|
||||||
</Button>
|
>
|
||||||
<Button
|
{pendingAction === "approve" ? "Approving..." : "Approve"}
|
||||||
variant="destructive"
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
onClick={onReject}
|
variant="destructive"
|
||||||
disabled={isPending}
|
size="sm"
|
||||||
>
|
onClick={onReject}
|
||||||
Reject
|
disabled={isPending}
|
||||||
</Button>
|
>
|
||||||
|
{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>
|
||||||
)}
|
) : null}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
ui/src/components/ApprovalPayload.test.tsx
Normal file
88
ui/src/components/ApprovalPayload.test.tsx
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,13 +5,33 @@ export const typeLabel: Record<string, string> = {
|
||||||
hire_agent: "Hire Agent",
|
hire_agent: "Hire Agent",
|
||||||
approve_ceo_strategy: "CEO Strategy",
|
approve_ceo_strategy: "CEO Strategy",
|
||||||
budget_override_required: "Budget Override",
|
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" */
|
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
|
||||||
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
|
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
|
||||||
const base = typeLabel[type] ?? type;
|
const base = typeLabel[type] ?? type;
|
||||||
if (type === "hire_agent" && payload?.name) {
|
const subject = approvalSubject(payload);
|
||||||
return `${base}: ${String(payload.name)}`;
|
if (subject) {
|
||||||
|
return `${base}: ${subject}`;
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +40,7 @@ export const typeIcon: Record<string, typeof UserPlus> = {
|
||||||
hire_agent: UserPlus,
|
hire_agent: UserPlus,
|
||||||
approve_ceo_strategy: Lightbulb,
|
approve_ceo_strategy: Lightbulb,
|
||||||
budget_override_required: ShieldAlert,
|
budget_override_required: ShieldAlert,
|
||||||
|
request_board_approval: ShieldCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultTypeIcon = 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 === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||||
if (type === "budget_override_required") return <BudgetOverridePayload 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} />;
|
return <CeoStrategyPayload payload={payload} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,12 @@ export function CommandPalette() {
|
||||||
const { data: issues = [] } = useQuery({
|
const { data: issues = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId && open,
|
enabled: !!selectedCompanyId && open && searchQuery.length === 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: searchedIssues = [] } = useQuery({
|
const { data: searchedIssues = [] } = useQuery({
|
||||||
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery),
|
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10),
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery }),
|
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10 }),
|
||||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { act } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { CommentThread } from "./CommentThread";
|
import { CommentThread } from "./CommentThread";
|
||||||
|
|
||||||
|
|
@ -33,6 +33,25 @@ vi.mock("./InlineEntitySelector", () => ({
|
||||||
InlineEntitySelector: () => null,
|
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", () => ({
|
vi.mock("@/plugins/slots", () => ({
|
||||||
PluginSlotOutlet: () => null,
|
PluginSlotOutlet: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
@ -144,4 +163,75 @@ describe("CommentThread", () => {
|
||||||
root.unmount();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
Approval,
|
||||||
FeedbackDataSharingPreference,
|
FeedbackDataSharingPreference,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
FeedbackVoteValue,
|
FeedbackVoteValue,
|
||||||
|
|
@ -15,7 +16,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { ApprovalCard } from "./ApprovalCard";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||||
|
|
@ -50,6 +51,7 @@ interface CommentReassignment {
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
comments: CommentWithRunMeta[];
|
comments: CommentWithRunMeta[];
|
||||||
queuedComments?: CommentWithRunMeta[];
|
queuedComments?: CommentWithRunMeta[];
|
||||||
|
linkedApprovals?: Approval[];
|
||||||
feedbackVotes?: FeedbackVote[];
|
feedbackVotes?: FeedbackVote[];
|
||||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||||
feedbackTermsUrl?: string | null;
|
feedbackTermsUrl?: string | null;
|
||||||
|
|
@ -57,6 +59,12 @@ interface CommentThreadProps {
|
||||||
timelineEvents?: IssueTimelineEvent[];
|
timelineEvents?: IssueTimelineEvent[];
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
onApproveApproval?: (approvalId: string) => Promise<void>;
|
||||||
|
onRejectApproval?: (approvalId: string) => Promise<void>;
|
||||||
|
pendingApprovalAction?: {
|
||||||
|
approvalId: string;
|
||||||
|
action: "approve" | "reject";
|
||||||
|
} | null;
|
||||||
onVote?: (
|
onVote?: (
|
||||||
commentId: string,
|
commentId: string,
|
||||||
vote: FeedbackVoteValue,
|
vote: FeedbackVoteValue,
|
||||||
|
|
@ -375,6 +383,7 @@ function CommentCard({
|
||||||
|
|
||||||
type TimelineItem =
|
type TimelineItem =
|
||||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
| { 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: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
|
||||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||||
|
|
||||||
|
|
@ -447,6 +456,9 @@ const TimelineList = memo(function TimelineList({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
companyId,
|
companyId,
|
||||||
projectId,
|
projectId,
|
||||||
|
onApproveApproval,
|
||||||
|
onRejectApproval,
|
||||||
|
pendingApprovalAction,
|
||||||
feedbackVoteByTargetId,
|
feedbackVoteByTargetId,
|
||||||
feedbackDataSharingPreference = "prompt",
|
feedbackDataSharingPreference = "prompt",
|
||||||
feedbackTermsUrl = null,
|
feedbackTermsUrl = null,
|
||||||
|
|
@ -459,6 +471,12 @@ const TimelineList = memo(function TimelineList({
|
||||||
currentUserId?: string | null;
|
currentUserId?: string | null;
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
projectId?: 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>;
|
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
|
||||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||||
feedbackTermsUrl?: string | null;
|
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") {
|
if (item.kind === "run") {
|
||||||
const run = item.run;
|
const run = item.run;
|
||||||
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||||
|
|
@ -548,6 +584,7 @@ const TimelineList = memo(function TimelineList({
|
||||||
export function CommentThread({
|
export function CommentThread({
|
||||||
comments,
|
comments,
|
||||||
queuedComments = [],
|
queuedComments = [],
|
||||||
|
linkedApprovals = [],
|
||||||
feedbackVotes = [],
|
feedbackVotes = [],
|
||||||
feedbackDataSharingPreference = "prompt",
|
feedbackDataSharingPreference = "prompt",
|
||||||
feedbackTermsUrl = null,
|
feedbackTermsUrl = null,
|
||||||
|
|
@ -555,6 +592,9 @@ export function CommentThread({
|
||||||
timelineEvents = [],
|
timelineEvents = [],
|
||||||
companyId,
|
companyId,
|
||||||
projectId,
|
projectId,
|
||||||
|
onApproveApproval,
|
||||||
|
onRejectApproval,
|
||||||
|
pendingApprovalAction = null,
|
||||||
onVote,
|
onVote,
|
||||||
onAdd,
|
onAdd,
|
||||||
agentMap,
|
agentMap,
|
||||||
|
|
@ -593,6 +633,12 @@ export function CommentThread({
|
||||||
createdAtMs: new Date(comment.createdAt).getTime(),
|
createdAtMs: new Date(comment.createdAt).getTime(),
|
||||||
comment,
|
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) => ({
|
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
|
||||||
kind: "event",
|
kind: "event",
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -605,17 +651,18 @@ export function CommentThread({
|
||||||
createdAtMs: new Date(runTimestamp(run)).getTime(),
|
createdAtMs: new Date(runTimestamp(run)).getTime(),
|
||||||
run,
|
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.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
|
||||||
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
||||||
const kindOrder = {
|
const kindOrder = {
|
||||||
event: 0,
|
event: 0,
|
||||||
comment: 1,
|
approval: 1,
|
||||||
run: 2,
|
comment: 2,
|
||||||
|
run: 3,
|
||||||
} as const;
|
} as const;
|
||||||
return kindOrder[a.kind] - kindOrder[b.kind];
|
return kindOrder[a.kind] - kindOrder[b.kind];
|
||||||
});
|
});
|
||||||
}, [comments, timelineEvents, linkedRuns]);
|
}, [comments, linkedApprovals, timelineEvents, linkedRuns]);
|
||||||
|
|
||||||
const feedbackVoteByTargetId = useMemo(() => {
|
const feedbackVoteByTargetId = useMemo(() => {
|
||||||
const map = new Map<string, FeedbackVoteValue>();
|
const map = new Map<string, FeedbackVoteValue>();
|
||||||
|
|
@ -754,6 +801,9 @@ export function CommentThread({
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
onApproveApproval={onApproveApproval}
|
||||||
|
onRejectApproval={onRejectApproval}
|
||||||
|
pendingApprovalAction={pendingApprovalAction}
|
||||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||||
onVote={onVote ? handleFeedbackVote : undefined}
|
onVote={onVote ? handleFeedbackVote : undefined}
|
||||||
|
|
|
||||||
265
ui/src/components/DocumentDiffModal.tsx
Normal file
265
ui/src/components/DocumentDiffModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,8 @@ interface InlineEditorProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
|
/** Called when a non-image file is dropped onto the editor. */
|
||||||
|
onDropFile?: (file: File) => Promise<void>;
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +48,7 @@ export function InlineEditor({
|
||||||
multiline = false,
|
multiline = false,
|
||||||
nullable = false,
|
nullable = false,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
|
onDropFile,
|
||||||
mentions,
|
mentions,
|
||||||
}: InlineEditorProps) {
|
}: InlineEditorProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
@ -228,6 +231,7 @@ export function InlineEditor({
|
||||||
className="bg-transparent"
|
className="bg-transparent"
|
||||||
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||||
imageUploadHandler={imageUploadHandler}
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
onDropFile={onDropFile}
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
finalizeMultilineBlurOrSubmit();
|
finalizeMultilineBlurOrSubmit();
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 = {
|
type DraftState = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -162,6 +163,7 @@ export function IssueDocumentsSection({
|
||||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||||
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||||
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | 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 autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const hasScrolledToHashRef = useRef(false);
|
const hasScrolledToHashRef = useRef(false);
|
||||||
|
|
@ -929,6 +931,12 @@ export function IssueDocumentsSection({
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Download document
|
Download document
|
||||||
</DropdownMenuItem>
|
</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 ? <DropdownMenuSeparator /> : null}
|
||||||
{canDeleteDocuments ? (
|
{canDeleteDocuments ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
@ -1174,6 +1182,20 @@ export function IssueDocumentsSection({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
204
ui/src/components/IssueProperties.test.tsx
Normal file
204
ui/src/components/IssueProperties.test.tsx
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
|
@ -19,9 +19,40 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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";
|
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: {
|
function defaultProjectWorkspaceIdForProject(project: {
|
||||||
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
||||||
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
||||||
|
|
@ -42,6 +73,8 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||||
|
|
||||||
interface IssuePropertiesProps {
|
interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
|
childIssues?: Issue[];
|
||||||
|
onAddSubIssue?: () => void;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
inline?: boolean;
|
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 { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
|
|
@ -683,6 +722,34 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
)}
|
)}
|
||||||
</PropertyRow>
|
</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 && (
|
{issue.parentId && (
|
||||||
<PropertyRow label="Parent">
|
<PropertyRow label="Parent">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -700,6 +767,30 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,7 @@ describe("IssueRow", () => {
|
||||||
|
|
||||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
|
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toBe("/issues/PAP-1");
|
||||||
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
|
|
@ -51,9 +51,10 @@ export function IssueRow({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
to={createIssueDetailPath(issuePathId)}
|
||||||
state={issueLinkState}
|
state={issueLinkState}
|
||||||
data-inbox-issue-link
|
data-inbox-issue-link
|
||||||
|
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)}
|
||||||
className={cn(
|
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",
|
"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",
|
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||||
|
|
|
||||||
187
ui/src/components/IssuesList.test.tsx
Normal file
187
ui/src/components/IssuesList.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -145,18 +145,6 @@ function countActiveFilters(state: IssueViewState): number {
|
||||||
return count;
|
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 ── */
|
/* ── Component ── */
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
|
|
@ -278,12 +266,10 @@ export function IssuesList({
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const sourceIssues = normalizedIssueSearch.length > 0
|
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||||
? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch))
|
|
||||||
: issues;
|
|
||||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||||
return sortIssues(filteredByControls, viewState);
|
return sortIssues(filteredByControls, viewState);
|
||||||
}, [issues, viewState, normalizedIssueSearch, currentUserId]);
|
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||||
|
|
||||||
const { data: labels } = useQuery({
|
const { data: labels } = useQuery({
|
||||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
|
import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
|
||||||
const mdxEditorMockState = vi.hoisted(() => ({
|
const mdxEditorMockState = vi.hoisted(() => ({
|
||||||
emitMountEmptyReset: false,
|
emitMountEmptyReset: false,
|
||||||
|
|
@ -186,4 +186,31 @@ describe("MarkdownEditor", () => {
|
||||||
left: 92,
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ interface MarkdownEditorProps {
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
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;
|
bordered?: boolean;
|
||||||
/** List of mentionable entities. Enables @-mention autocomplete. */
|
/** List of mentionable entities. Enables @-mention autocomplete. */
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
|
|
@ -108,9 +110,16 @@ interface MentionMenuViewport {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MentionMenuSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
const MENTION_MENU_WIDTH = 188;
|
const MENTION_MENU_WIDTH = 188;
|
||||||
const MENTION_MENU_HEIGHT = 208;
|
const MENTION_MENU_HEIGHT = 208;
|
||||||
const MENTION_MENU_PADDING = 8;
|
const MENTION_MENU_PADDING = 8;
|
||||||
|
const MENTION_MENU_ROW_HEIGHT = 34;
|
||||||
|
const MENTION_MENU_CHROME_HEIGHT = 8;
|
||||||
|
|
||||||
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||||
txt: "Text",
|
txt: "Text",
|
||||||
|
|
@ -140,19 +149,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
|
||||||
Editor: CodeMirrorEditor,
|
Editor: CodeMirrorEditor,
|
||||||
};
|
};
|
||||||
|
|
||||||
function detectMention(container: HTMLElement): MentionState | null {
|
export function findMentionMatch(
|
||||||
const sel = window.getSelection();
|
text: string,
|
||||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
offset: number,
|
||||||
|
): Pick<MentionState, "trigger" | "marker" | "query" | "atPos" | "endPos"> | 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.
|
|
||||||
let atPos = -1;
|
let atPos = -1;
|
||||||
let trigger: MentionState["trigger"] | null = null;
|
let trigger: MentionState["trigger"] | null = null;
|
||||||
let marker: MentionState["marker"] | null = null;
|
let marker: MentionState["marker"] | null = null;
|
||||||
|
|
@ -166,31 +166,54 @@ function detectMention(container: HTMLElement): MentionState | null {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (/\s/.test(ch)) break;
|
if (ch === "\n" || ch === "\r") break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (atPos === -1) return null;
|
if (atPos === -1) return null;
|
||||||
|
|
||||||
const query = text.slice(atPos + 1, offset);
|
const query = text.slice(atPos + 1, offset);
|
||||||
|
if (trigger === "skill" && /\s/.test(query)) return null;
|
||||||
// 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();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trigger: trigger ?? "mention",
|
trigger: trigger ?? "mention",
|
||||||
marker: marker ?? "@",
|
marker: marker ?? "@",
|
||||||
query,
|
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,
|
top: rect.bottom - containerRect.top,
|
||||||
left: rect.left - containerRect.left,
|
left: rect.left - containerRect.left,
|
||||||
viewportTop: rect.bottom,
|
viewportTop: rect.bottom,
|
||||||
viewportLeft: rect.left,
|
viewportLeft: rect.left,
|
||||||
textNode: textNode as Text,
|
textNode: textNode as Text,
|
||||||
atPos,
|
atPos: match.atPos,
|
||||||
endPos: offset,
|
endPos: match.endPos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,11 +239,12 @@ function getMentionMenuViewport(): MentionMenuViewport {
|
||||||
export function computeMentionMenuPosition(
|
export function computeMentionMenuPosition(
|
||||||
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
|
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
|
||||||
viewport: MentionMenuViewport,
|
viewport: MentionMenuViewport,
|
||||||
|
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
|
||||||
) {
|
) {
|
||||||
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
|
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 minTop = viewport.offsetTop + MENTION_MENU_PADDING;
|
||||||
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
|
const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
|
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 {
|
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
||||||
if (!node || !container.contains(node)) return false;
|
if (!node || !container.contains(node)) return false;
|
||||||
const el = node.nodeType === Node.ELEMENT_NODE
|
const el = node.nodeType === Node.ELEMENT_NODE
|
||||||
|
|
@ -281,6 +316,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
contentClassName,
|
contentClassName,
|
||||||
onBlur,
|
onBlur,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
|
onDropFile,
|
||||||
bordered = true,
|
bordered = true,
|
||||||
mentions,
|
mentions,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -635,6 +671,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDropImage = Boolean(imageUploadHandler);
|
const canDropImage = Boolean(imageUploadHandler);
|
||||||
|
const canDropFile = Boolean(imageUploadHandler || onDropFile);
|
||||||
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
const clipboard = event.clipboardData;
|
const clipboard = event.clipboardData;
|
||||||
if (!clipboard || !ref.current) return;
|
if (!clipboard || !ref.current) return;
|
||||||
|
|
@ -650,7 +687,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const mentionMenuPosition = mentionState
|
const mentionMenuPosition = mentionState
|
||||||
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
|
? computeMentionMenuPosition(
|
||||||
|
mentionState,
|
||||||
|
getMentionMenuViewport(),
|
||||||
|
getMentionMenuSize(filteredMentions.length),
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -673,8 +714,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
|
|
||||||
// Mention keyboard handling
|
// Mention keyboard handling
|
||||||
if (mentionActive) {
|
if (mentionActive) {
|
||||||
// Space dismisses the popup (let the character be typed normally)
|
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
|
||||||
if (e.key === " ") {
|
|
||||||
mentionStateRef.current = null;
|
mentionStateRef.current = null;
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -711,23 +751,41 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnter={(evt) => {
|
onDragEnter={(evt) => {
|
||||||
if (!canDropImage || !hasFilePayload(evt)) return;
|
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||||
dragDepthRef.current += 1;
|
dragDepthRef.current += 1;
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}}
|
}}
|
||||||
onDragOver={(evt) => {
|
onDragOver={(evt) => {
|
||||||
if (!canDropImage || !hasFilePayload(evt)) return;
|
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.dataTransfer.dropEffect = "copy";
|
evt.dataTransfer.dropEffect = "copy";
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => {
|
onDragLeave={() => {
|
||||||
if (!canDropImage) return;
|
if (!canDropFile) return;
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||||
}}
|
}}
|
||||||
onDrop={() => {
|
onDrop={(evt) => {
|
||||||
dragDepthRef.current = 0;
|
dragDepthRef.current = 0;
|
||||||
setIsDragOver(false);
|
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}
|
onPasteCapture={handlePasteCapture}
|
||||||
>
|
>
|
||||||
|
|
@ -818,14 +876,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDragOver && canDropImage && (
|
{isDragOver && canDropFile && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
!bordered && "inset-0 rounded-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Drop image to upload
|
Drop {onDropFile ? "file" : "image"} to upload
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
|
|
|
||||||
439
ui/src/components/NewIssueDialog.test.tsx
Normal file
439
ui/src/components/NewIssueDialog.test.tsx
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
Paperclip,
|
Paperclip,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ListTree,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -297,6 +298,11 @@ export function NewIssueDialog() {
|
||||||
|
|
||||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
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
|
// Popover states
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
|
|
@ -510,7 +516,28 @@ export function NewIssueDialog() {
|
||||||
executionWorkspaceDefaultProjectId.current = null;
|
executionWorkspaceDefaultProjectId.current = null;
|
||||||
|
|
||||||
const draft = loadDraft();
|
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);
|
setTitle(newIssueDefaults.title);
|
||||||
setDescription(newIssueDefaults.description ?? "");
|
setDescription(newIssueDefaults.description ?? "");
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
|
|
@ -616,6 +643,7 @@ export function NewIssueDialog() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCompanyChange(companyId: string) {
|
function handleCompanyChange(companyId: string) {
|
||||||
|
if (isSubIssueMode) return;
|
||||||
if (companyId === effectiveCompanyId) return;
|
if (companyId === effectiveCompanyId) return;
|
||||||
setDialogCompanyId(companyId);
|
setDialogCompanyId(companyId);
|
||||||
setAssigneeValue("");
|
setAssigneeValue("");
|
||||||
|
|
@ -666,6 +694,8 @@ export function NewIssueDialog() {
|
||||||
priority: priority || "medium",
|
priority: priority || "medium",
|
||||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||||
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||||
|
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
|
||||||
|
...(newIssueDefaults.goalId ? { goalId: newIssueDefaults.goalId } : {}),
|
||||||
...(projectId ? { projectId } : {}),
|
...(projectId ? { projectId } : {}),
|
||||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||||
|
|
@ -774,6 +804,13 @@ export function NewIssueDialog() {
|
||||||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||||
);
|
);
|
||||||
|
const isUsingParentExecutionWorkspace = isSubIssueMode && parentExecutionWorkspaceId
|
||||||
|
? executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId === parentExecutionWorkspaceId
|
||||||
|
: false;
|
||||||
|
const showParentWorkspaceWarning = isSubIssueMode
|
||||||
|
&& currentProjectSupportsExecutionWorkspace
|
||||||
|
&& Boolean(parentExecutionWorkspaceId)
|
||||||
|
&& !isUsingParentExecutionWorkspace;
|
||||||
const assigneeOptionsTitle =
|
const assigneeOptionsTitle =
|
||||||
assigneeAdapterType === "claude_local"
|
assigneeAdapterType === "claude_local"
|
||||||
? "Claude options"
|
? "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",
|
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
||||||
!dialogCompany?.brandColor && "bg-muted",
|
!dialogCompany?.brandColor && "bg-muted",
|
||||||
)}
|
)}
|
||||||
|
disabled={isSubIssueMode}
|
||||||
style={
|
style={
|
||||||
dialogCompany?.brandColor
|
dialogCompany?.brandColor
|
||||||
? {
|
? {
|
||||||
|
|
@ -955,7 +993,7 @@ export function NewIssueDialog() {
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<span className="text-muted-foreground/60">›</span>
|
<span className="text-muted-foreground/60">›</span>
|
||||||
<span>New issue</span>
|
<span>{isSubIssueMode ? "New sub-issue" : "New issue"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1119,6 +1157,23 @@ export function NewIssueDialog() {
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||||
<div className="px-4 py-3 shrink-0 space-y-2">
|
<div className="px-4 py-3 shrink-0 space-y-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -1161,6 +1216,11 @@ export function NewIssueDialog() {
|
||||||
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
|
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1455,7 +1515,7 @@ export function NewIssueDialog() {
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1.5">
|
<span className="inline-flex items-center justify-center gap-1.5">
|
||||||
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
{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>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ArrowDown } from "lucide-react";
|
import { ArrowDown } from "lucide-react";
|
||||||
|
import { usePanel } from "../context/PanelContext";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
function resolveScrollTarget() {
|
function resolveScrollTarget() {
|
||||||
const mainContent = document.getElementById("main-content");
|
const mainContent = document.getElementById("main-content");
|
||||||
|
|
@ -33,6 +35,7 @@ function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
|
||||||
*/
|
*/
|
||||||
export function ScrollToBottom() {
|
export function ScrollToBottom() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const { panelVisible, panelContent } = usePanel();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => {
|
const check = () => {
|
||||||
|
|
@ -70,7 +73,10 @@ export function ScrollToBottom() {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={scroll}
|
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"
|
aria-label="Scroll to bottom"
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,14 @@ interface NewIssueDefaults {
|
||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
projectWorkspaceId?: string;
|
||||||
|
goalId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
parentIdentifier?: string;
|
||||||
|
parentTitle?: string;
|
||||||
|
executionWorkspaceId?: string;
|
||||||
|
executionWorkspaceMode?: string;
|
||||||
|
parentExecutionWorkspaceLabel?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
|
||||||
|
|
@ -515,13 +515,14 @@ describe("inbox helpers", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the workspace column option unless isolated workspaces are enabled", () => {
|
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([
|
expect(getAvailableInboxIssueColumns(true)).toEqual([
|
||||||
"status",
|
"status",
|
||||||
"id",
|
"id",
|
||||||
"assignee",
|
"assignee",
|
||||||
"project",
|
"project",
|
||||||
"workspace",
|
"workspace",
|
||||||
|
"parent",
|
||||||
"labels",
|
"labels",
|
||||||
"updated",
|
"updated",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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 const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
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 type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||||
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||||
export type InboxWorkItem =
|
export type InboxWorkItem =
|
||||||
|
|
|
||||||
|
|
@ -3,50 +3,80 @@ import {
|
||||||
armIssueDetailInboxQuickArchive,
|
armIssueDetailInboxQuickArchive,
|
||||||
createIssueDetailLocationState,
|
createIssueDetailLocationState,
|
||||||
createIssueDetailPath,
|
createIssueDetailPath,
|
||||||
|
hasLegacyIssueDetailQuery,
|
||||||
|
readIssueDetailLocationState,
|
||||||
readIssueDetailBreadcrumb,
|
readIssueDetailBreadcrumb,
|
||||||
|
rememberIssueDetailLocationState,
|
||||||
shouldArmIssueDetailInboxQuickArchive,
|
shouldArmIssueDetailInboxQuickArchive,
|
||||||
} from "./issueDetailBreadcrumb";
|
} 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", () => {
|
describe("issueDetailBreadcrumb", () => {
|
||||||
|
it("returns clean issue detail paths", () => {
|
||||||
|
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers the full breadcrumb from route state", () => {
|
it("prefers the full breadcrumb from route state", () => {
|
||||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||||
|
|
||||||
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
|
expect(readIssueDetailBreadcrumb("PAP-465", state, "?from=issues")).toEqual({
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
href: "/inbox/mine",
|
href: "/inbox/mine",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the source query param when route state is unavailable", () => {
|
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",
|
label: "Inbox",
|
||||||
href: "/inbox",
|
href: "/inbox",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds the source query param when building an issue detail path", () => {
|
it("can detect legacy query-based breadcrumb links", () => {
|
||||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
expect(hasLegacyIssueDetailQuery("?from=inbox&fromHref=%2Finbox%2Fmine")).toBe(true);
|
||||||
|
expect(hasLegacyIssueDetailQuery("?q=test")).toBe(false);
|
||||||
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("restores the exact breadcrumb href from the query fallback", () => {
|
it("restores the exact breadcrumb href from the query fallback", () => {
|
||||||
expect(
|
expect(
|
||||||
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
readIssueDetailBreadcrumb("PAP-465", null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
href: "/PAP/inbox/unread",
|
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", () => {
|
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
||||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ type IssueDetailLocationState = {
|
||||||
|
|
||||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||||
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
|
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 {
|
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||||
if (typeof value !== "object" || value === null) return false;
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
|
@ -44,6 +45,17 @@ function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null
|
||||||
return href && href.startsWith("/") ? href : 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 {
|
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
||||||
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
||||||
return { label: "Issues", href: "/issues" };
|
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 {
|
function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLocationState | null {
|
||||||
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
|
if (typeof window === "undefined" || !window.sessionStorage) return null;
|
||||||
const breadcrumb =
|
|
||||||
(typeof state === "object" && state !== null
|
const raw = window.sessionStorage.getItem(`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`);
|
||||||
? (state as IssueDetailLocationState).issueDetailBreadcrumb
|
if (!raw) return null;
|
||||||
: null);
|
|
||||||
const breadcrumbHref =
|
try {
|
||||||
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
|
const parsed = JSON.parse(raw) as Partial<IssueDetailLocationState>;
|
||||||
readIssueDetailBreadcrumbHrefFromSearch(search);
|
const breadcrumb = isIssueDetailBreadcrumb(parsed.issueDetailBreadcrumb)
|
||||||
if (!source) return `/issues/${issuePathId}`;
|
? parsed.issueDetailBreadcrumb
|
||||||
const params = new URLSearchParams();
|
: null;
|
||||||
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
|
const source = inferIssueDetailSource(parsed, breadcrumb);
|
||||||
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
|
if (!breadcrumb || !source) return null;
|
||||||
return `/issues/${issuePathId}?${params.toString()}`;
|
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) {
|
if (typeof state === "object" && state !== null) {
|
||||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
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 source = readIssueDetailSourceFromSearch(search);
|
||||||
|
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
|
|
||||||
const fallback = breadcrumbForSource(source);
|
return {
|
||||||
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
issueDetailBreadcrumb: href ? { ...breadcrumbForSource(source), href } : breadcrumbForSource(source),
|
||||||
return href ? { ...fallback, href } : fallback;
|
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 {
|
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
|
||||||
|
|
|
||||||
|
|
@ -212,4 +212,18 @@ describe("optimistic issue comments", () => {
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,20 @@ export function createOptimisticIssueComment(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQueuedIssueComment(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;
|
activeRunStartedAt?: Date | string | null;
|
||||||
|
activeRunAgentId?: string | null;
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
interruptedRunId?: string | null;
|
interruptedRunId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (params.runId) return false;
|
if (params.runId) return false;
|
||||||
if (params.interruptedRunId) 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.comment.clientStatus === "queued") return true;
|
||||||
if (!params.activeRunStartedAt) return false;
|
if (!params.activeRunStartedAt) return false;
|
||||||
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
|
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export const queryKeys = {
|
||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
list: (companyId: string) => ["issues", companyId] as const,
|
list: (companyId: string) => ["issues", companyId] as const,
|
||||||
search: (companyId: string, q: string, projectId?: string) =>
|
search: (companyId: string, q: string, projectId?: string, limit?: number) =>
|
||||||
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
|
["issues", companyId, "search", q, projectId ?? "__all-projects__", limit ?? "__no-limit__"] as const,
|
||||||
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
|
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
|
||||||
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
||||||
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,9 @@ export function Approvals() {
|
||||||
onReject={() => rejectMutation.mutate(approval.id)}
|
onReject={() => rejectMutation.mutate(approval.id)}
|
||||||
detailLink={`/approvals/${approval.id}`}
|
detailLink={`/approvals/${approval.id}`}
|
||||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||||
|
pendingAction={
|
||||||
|
approveMutation.isPending ? "approve" : rejectMutation.isPending ? "reject" : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
||||||
|
|
@ -393,19 +393,20 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||||
<div className="space-y-6">
|
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||||
<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-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<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">
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
Execution workspace
|
Execution workspace
|
||||||
</div>
|
</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">
|
<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,
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
<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">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||||
<textarea
|
<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}
|
value={form.provisionCommand}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||||
placeholder="bash ./scripts/provision-worktree.sh"
|
placeholder="bash ./scripts/provision-worktree.sh"
|
||||||
|
|
@ -490,7 +491,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||||
<textarea
|
<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}
|
value={form.teardownCommand}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||||
|
|
@ -501,7 +502,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
<div className="mt-4 grid gap-4">
|
<div className="mt-4 grid gap-4">
|
||||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||||
<textarea
|
<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}
|
value={form.cleanupCommand}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||||
placeholder="pkill -f vite || true"
|
placeholder="pkill -f vite || true"
|
||||||
|
|
@ -546,14 +547,22 @@ export function ExecutionWorkspaceDetail() {
|
||||||
id="inherit-runtime-config"
|
id="inherit-runtime-config"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.inheritRuntime}
|
checked={form.inheritRuntime}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
|
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>
|
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<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}
|
value={form.workspaceRuntime}
|
||||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||||
disabled={form.inheritRuntime}
|
disabled={form.inheritRuntime}
|
||||||
|
|
@ -586,8 +595,8 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||||
<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="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
<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>
|
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||||
|
|
@ -632,7 +641,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
</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="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
<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>
|
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||||
|
|
@ -676,7 +685,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
</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-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
<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>
|
||||||
|
|
||||||
<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="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
<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>
|
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||||
|
|
@ -798,7 +807,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
</div>
|
</div>
|
||||||
</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="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
<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."}
|
: "Failed to load linked issues."}
|
||||||
</p>
|
</p>
|
||||||
) : linkedIssues.length > 0 ? (
|
) : 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) => (
|
{linkedIssues.map((issue) => (
|
||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={issueUrl(issue)}
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,8 @@ describe("InboxIssueTrailingColumns", () => {
|
||||||
workspaceName={null}
|
workspaceName={null}
|
||||||
assigneeName={null}
|
assigneeName={null}
|
||||||
currentUserId={null}
|
currentUserId={null}
|
||||||
|
parentIdentifier={null}
|
||||||
|
parentTitle={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -229,6 +231,8 @@ describe("InboxIssueTrailingColumns", () => {
|
||||||
workspaceName={null}
|
workspaceName={null}
|
||||||
assigneeName={null}
|
assigneeName={null}
|
||||||
currentUserId={null}
|
currentUserId={null}
|
||||||
|
parentIdentifier={null}
|
||||||
|
parentTitle={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
armIssueDetailInboxQuickArchive,
|
armIssueDetailInboxQuickArchive,
|
||||||
createIssueDetailLocationState,
|
createIssueDetailLocationState,
|
||||||
createIssueDetailPath,
|
createIssueDetailPath,
|
||||||
|
rememberIssueDetailLocationState,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
|
@ -140,13 +141,14 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
|
|
||||||
|
|
||||||
type NonIssueUnreadState = "visible" | "fading" | "hidden" | 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> = {
|
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||||
status: "Status",
|
status: "Status",
|
||||||
id: "ID",
|
id: "ID",
|
||||||
assignee: "Assignee",
|
assignee: "Assignee",
|
||||||
project: "Project",
|
project: "Project",
|
||||||
workspace: "Workspace",
|
workspace: "Workspace",
|
||||||
|
parent: "Parent issue",
|
||||||
labels: "Tags",
|
labels: "Tags",
|
||||||
updated: "Last updated",
|
updated: "Last updated",
|
||||||
};
|
};
|
||||||
|
|
@ -156,6 +158,7 @@ const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
||||||
assignee: "Assigned agent or board user.",
|
assignee: "Assigned agent or board user.",
|
||||||
project: "Linked project pill with its color.",
|
project: "Linked project pill with its color.",
|
||||||
workspace: "Execution or project workspace used for the issue.",
|
workspace: "Execution or project workspace used for the issue.",
|
||||||
|
parent: "Parent issue identifier and title.",
|
||||||
labels: "Issue labels and tags.",
|
labels: "Issue labels and tags.",
|
||||||
updated: "Latest visible activity time.",
|
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 === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||||
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||||
if (column === "workspace") return "minmax(9rem, 12rem)";
|
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||||
|
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||||
if (column === "labels") return "minmax(8rem, 10rem)";
|
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||||
return "minmax(6rem, 7rem)";
|
return "minmax(4rem, 5.5rem)";
|
||||||
})
|
})
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +241,8 @@ export function InboxIssueTrailingColumns({
|
||||||
workspaceName,
|
workspaceName,
|
||||||
assigneeName,
|
assigneeName,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
parentIdentifier,
|
||||||
|
parentTitle,
|
||||||
}: {
|
}: {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
columns: InboxIssueColumn[];
|
columns: InboxIssueColumn[];
|
||||||
|
|
@ -245,6 +251,8 @@ export function InboxIssueTrailingColumns({
|
||||||
workspaceName: string | null;
|
workspaceName: string | null;
|
||||||
assigneeName: string | null;
|
assigneeName: string | null;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
|
parentIdentifier: string | null;
|
||||||
|
parentTitle: string | null;
|
||||||
}) {
|
}) {
|
||||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
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 (
|
return (
|
||||||
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||||
{activityText}
|
{activityText}
|
||||||
|
|
@ -1245,30 +1269,53 @@ export function Inbox() {
|
||||||
|
|
||||||
const archiveIssueMutation = useMutation({
|
const archiveIssueMutation = useMutation({
|
||||||
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
||||||
onMutate: (id) => {
|
onMutate: async (id) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
setArchivingIssueIds((prev) => new Set(prev).add(id));
|
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: () => {
|
onError: (err, id, context) => {
|
||||||
invalidateInboxIssueQueries();
|
|
||||||
},
|
|
||||||
onError: (err, id) => {
|
|
||||||
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
|
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
|
||||||
setArchivingIssueIds((prev) => {
|
setArchivingIssueIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
return next;
|
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) => {
|
onSettled: (_data, error, id) => {
|
||||||
if (error) return;
|
// Clean up archiving state and refetch to sync with server
|
||||||
window.setTimeout(() => {
|
setArchivingIssueIds((prev) => {
|
||||||
setArchivingIssueIds((prev) => {
|
const next = new Set(prev);
|
||||||
const next = new Set(prev);
|
next.delete(id);
|
||||||
next.delete(id);
|
return next;
|
||||||
return next;
|
});
|
||||||
});
|
invalidateInboxIssueQueries();
|
||||||
}, 500);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1498,7 +1545,8 @@ export function Inbox() {
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
const pathId = item.issue.identifier ?? item.issue.id;
|
const pathId = item.issue.identifier ?? item.issue.id;
|
||||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
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") {
|
} else if (item.kind === "approval") {
|
||||||
act.navigate(`/approvals/${item.approval.id}`);
|
act.navigate(`/approvals/${item.approval.id}`);
|
||||||
} else if (item.kind === "failed_run") {
|
} else if (item.kind === "failed_run") {
|
||||||
|
|
@ -1566,7 +1614,19 @@ export function Inbox() {
|
||||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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}`)}>
|
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||||
<PageTabBar
|
<PageTabBar
|
||||||
items={[
|
items={[
|
||||||
|
|
@ -1585,14 +1645,14 @@ export function Inbox() {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<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
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search inbox…"
|
placeholder="Search inbox…"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -1601,7 +1661,7 @@ export function Inbox() {
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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" />
|
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||||
Show / hide columns
|
Show / hide columns
|
||||||
|
|
@ -1685,6 +1745,7 @@ export function Inbox() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "all" && (
|
{tab === "all" && (
|
||||||
|
|
@ -1941,6 +2002,8 @@ export function Inbox() {
|
||||||
})}
|
})}
|
||||||
assigneeName={agentName(issue.assigneeAgentId)}
|
assigneeName={agentName(issue.assigneeAgentId)}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
|
||||||
|
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
|
||||||
/>
|
/>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
|
@ -10,6 +11,7 @@ import { agentsApi } from "../api/agents";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
|
@ -17,8 +19,11 @@ import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../li
|
||||||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
|
hasLegacyIssueDetailQuery,
|
||||||
createIssueDetailPath,
|
createIssueDetailPath,
|
||||||
|
readIssueDetailLocationState,
|
||||||
readIssueDetailBreadcrumb,
|
readIssueDetailBreadcrumb,
|
||||||
|
rememberIssueDetailLocationState,
|
||||||
shouldArmIssueDetailInboxQuickArchive,
|
shouldArmIssueDetailInboxQuickArchive,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
||||||
|
|
@ -33,6 +38,7 @@ import {
|
||||||
} from "../lib/optimistic-issue-comments";
|
} from "../lib/optimistic-issue-comments";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||||
|
import { ApprovalCard } from "../components/ApprovalCard";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { CommentThread } from "../components/CommentThread";
|
import { CommentThread } from "../components/CommentThread";
|
||||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||||
|
|
@ -44,21 +50,18 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Activity as ActivityIcon,
|
Activity as ActivityIcon,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -287,6 +290,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
||||||
export function IssueDetail() {
|
export function IssueDetail() {
|
||||||
const { issueId } = useParams<{ issueId: string }>();
|
const { issueId } = useParams<{ issueId: string }>();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
const { openNewIssue } = useDialog();
|
||||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -297,9 +301,11 @@ export function IssueDetail() {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||||
const [detailTab, setDetailTab] = useState("comments");
|
const [detailTab, setDetailTab] = useState("comments");
|
||||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||||
approvals: false,
|
approvalId: string;
|
||||||
});
|
action: "approve" | "reject";
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||||
|
|
@ -375,9 +381,13 @@ export function IssueDetail() {
|
||||||
),
|
),
|
||||||
[activeRun, liveRuns],
|
[activeRun, liveRuns],
|
||||||
);
|
);
|
||||||
|
const resolvedIssueDetailState = useMemo(
|
||||||
|
() => readIssueDetailLocationState(issueId, location.state, location.search),
|
||||||
|
[issueId, location.state, location.search],
|
||||||
|
);
|
||||||
const sourceBreadcrumb = useMemo(
|
const sourceBreadcrumb = useMemo(
|
||||||
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||||
[location.state, location.search],
|
[issueId, location.state, location.search],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out runs already shown by the live widget to avoid duplication
|
// 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)
|
.filter((i) => i.parentId === issue.id)
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
}, [allIssues, issue]);
|
}, [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 commentReassignOptions = useMemo(() => {
|
||||||
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
||||||
|
|
@ -546,6 +595,7 @@ export function IssueDetail() {
|
||||||
isQueuedIssueComment({
|
isQueuedIssueComment({
|
||||||
comment: nextComment,
|
comment: nextComment,
|
||||||
activeRunStartedAt,
|
activeRunStartedAt,
|
||||||
|
activeRunAgentId: runningIssueRun?.agentId ?? null,
|
||||||
runId: meta?.runId ?? nextComment.runId ?? null,
|
runId: meta?.runId ?? nextComment.runId ?? null,
|
||||||
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
||||||
})
|
})
|
||||||
|
|
@ -650,6 +700,42 @@ export function IssueDetail() {
|
||||||
invalidateIssue();
|
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({
|
const addComment = useMutation({
|
||||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
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
|
// Redirect to identifier-based URL if navigated via UUID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const nextState = resolvedIssueDetailState ?? location.state;
|
||||||
if (issue?.identifier && issueId !== issue.identifier) {
|
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,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!issue?.id) return;
|
if (!issue?.id) return;
|
||||||
|
|
@ -983,13 +1080,20 @@ export function IssueDetail() {
|
||||||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue) {
|
if (!issue) {
|
||||||
openPanel(
|
closePanel();
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
openPanel(
|
||||||
|
<IssueProperties
|
||||||
|
issue={issue}
|
||||||
|
childIssues={childIssues}
|
||||||
|
onAddSubIssue={openNewSubIssue}
|
||||||
|
onUpdate={handleIssuePropertiesUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
|
||||||
|
|
||||||
const inboxQuickArchiveArmedRef = useRef(false);
|
const inboxQuickArchiveArmedRef = useRef(false);
|
||||||
const canQuickArchiveFromInbox =
|
const canQuickArchiveFromInbox =
|
||||||
|
|
@ -1115,13 +1219,13 @@ export function IssueDetail() {
|
||||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||||
const attachmentList = attachments ?? [];
|
const attachmentList = attachments ?? [];
|
||||||
const imageAttachments = attachmentList.filter(isImageAttachment);
|
const imageAttachments = attachmentList.filter(isImageAttachment);
|
||||||
|
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
|
||||||
const hasAttachments = attachmentList.length > 0;
|
const hasAttachments = attachmentList.length > 0;
|
||||||
const attachmentUploadButton = (
|
const attachmentUploadButton = (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFilePicked}
|
onChange={handleFilePicked}
|
||||||
multiple
|
multiple
|
||||||
|
|
@ -1156,8 +1260,14 @@ export function IssueDetail() {
|
||||||
<span key={ancestor.id} className="flex items-center gap-1">
|
<span key={ancestor.id} className="flex items-center gap-1">
|
||||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||||
<Link
|
<Link
|
||||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
|
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
|
||||||
state={location.state}
|
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]"
|
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||||
title={ancestor.title}
|
title={ancestor.title}
|
||||||
>
|
>
|
||||||
|
|
@ -1330,6 +1440,9 @@ export function IssueDetail() {
|
||||||
const attachment = await uploadAttachment.mutateAsync(file);
|
const attachment = await uploadAttachment.mutateAsync(file);
|
||||||
return attachment.contentPath;
|
return attachment.contentPath;
|
||||||
}}
|
}}
|
||||||
|
onDropFile={async (file) => {
|
||||||
|
await uploadAttachment.mutateAsync(file);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1374,6 +1487,50 @@ export function IssueDetail() {
|
||||||
missingBehavior="placeholder"
|
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
|
<IssueDocumentsSection
|
||||||
issue={issue}
|
issue={issue}
|
||||||
canDeleteDocuments={Boolean(session?.user?.id)}
|
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||||
|
|
@ -1395,7 +1552,18 @@ export function IssueDetail() {
|
||||||
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
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 ? (
|
{hasAttachments ? (
|
||||||
|
|
@ -1426,53 +1594,105 @@ export function IssueDetail() {
|
||||||
<p className="text-xs text-destructive">{attachmentError}</p>
|
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{imageAttachments.length > 0 && (
|
||||||
{attachmentList.map((attachment) => (
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
{imageAttachments.map((attachment) => (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div
|
||||||
<a
|
key={attachment.id}
|
||||||
href={attachment.contentPath}
|
className="group relative aspect-square rounded-lg overflow-hidden border border-border bg-accent/10 cursor-pointer"
|
||||||
target="_blank"
|
onClick={() => {
|
||||||
rel="noreferrer"
|
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
|
||||||
className="text-xs hover:underline truncate"
|
setGalleryIndex(idx >= 0 ? idx : 0);
|
||||||
title={attachment.originalFilename ?? attachment.id}
|
setGalleryOpen(true);
|
||||||
>
|
}}
|
||||||
{attachment.originalFilename ?? attachment.id}
|
>
|
||||||
</a>
|
<img
|
||||||
<button
|
src={attachment.contentPath}
|
||||||
type="button"
|
alt={attachment.originalFilename ?? "attachment"}
|
||||||
className="text-muted-foreground hover:text-destructive"
|
className="h-full w-full object-cover"
|
||||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
loading="lazy"
|
||||||
disabled={deleteAttachment.isPending}
|
/>
|
||||||
title="Delete attachment"
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors" />
|
||||||
>
|
{confirmDeleteId === attachment.id ? (
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<div
|
||||||
</button>
|
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>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
))}
|
||||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
</div>
|
||||||
</p>
|
)}
|
||||||
{isImageAttachment(attachment) && (
|
|
||||||
<button
|
{nonImageAttachments.length > 0 && (
|
||||||
type="button"
|
<div className="space-y-2">
|
||||||
className="block w-full text-left"
|
{nonImageAttachments.map((attachment) => (
|
||||||
onClick={() => {
|
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||||
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
|
<div className="flex items-center justify-between gap-2">
|
||||||
setGalleryIndex(idx >= 0 ? idx : 0);
|
<a
|
||||||
setGalleryOpen(true);
|
href={attachment.contentPath}
|
||||||
}}
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
<img
|
className="text-xs hover:underline truncate"
|
||||||
src={attachment.contentPath}
|
title={attachment.originalFilename ?? attachment.id}
|
||||||
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"
|
{attachment.originalFilename ?? attachment.id}
|
||||||
loading="lazy"
|
</a>
|
||||||
/>
|
<button
|
||||||
</button>
|
type="button"
|
||||||
)}
|
className="text-muted-foreground hover:text-destructive"
|
||||||
</div>
|
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||||
))}
|
disabled={deleteAttachment.isPending}
|
||||||
</div>
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -1497,10 +1717,6 @@ export function IssueDetail() {
|
||||||
<MessageSquare className="h-3.5 w-3.5" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
Comments
|
Comments
|
||||||
</TabsTrigger>
|
</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">
|
<TabsTrigger value="activity" className="gap-1.5">
|
||||||
<ActivityIcon className="h-3.5 w-3.5" />
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
Activity
|
Activity
|
||||||
|
|
@ -1516,6 +1732,7 @@ export function IssueDetail() {
|
||||||
<CommentThread
|
<CommentThread
|
||||||
comments={timelineComments}
|
comments={timelineComments}
|
||||||
queuedComments={queuedComments}
|
queuedComments={queuedComments}
|
||||||
|
linkedApprovals={linkedApprovals}
|
||||||
feedbackVotes={feedbackVotes}
|
feedbackVotes={feedbackVotes}
|
||||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||||
|
|
@ -1523,6 +1740,13 @@ export function IssueDetail() {
|
||||||
timelineEvents={timelineEvents}
|
timelineEvents={timelineEvents}
|
||||||
companyId={issue.companyId}
|
companyId={issue.companyId}
|
||||||
projectId={issue.projectId}
|
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}
|
issueStatus={issue.status}
|
||||||
agentMap={agentMap}
|
agentMap={agentMap}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
|
@ -1565,39 +1789,27 @@ export function IssueDetail() {
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="subissues">
|
<TabsContent value="activity">
|
||||||
{childIssues.length === 0 ? (
|
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">No sub-issues.</p>
|
<div className="mb-3 space-y-3">
|
||||||
) : (
|
{linkedApprovals.map((approval) => (
|
||||||
<div className="border border-border rounded-lg divide-y divide-border">
|
<ApprovalCard
|
||||||
{childIssues.map((child) => (
|
key={approval.id}
|
||||||
<Link
|
approval={approval}
|
||||||
key={child.id}
|
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
|
||||||
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
|
onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })}
|
||||||
state={location.state}
|
onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })}
|
||||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
detailLink={`/approvals/${approval.id}`}
|
||||||
>
|
isPending={pendingApprovalAction?.approvalId === approval.id}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
pendingAction={
|
||||||
<StatusIcon status={child.status} />
|
pendingApprovalAction?.approvalId === approval.id
|
||||||
<PriorityIcon priority={child.priority} />
|
? pendingApprovalAction.action
|
||||||
<span className="font-mono text-muted-foreground shrink-0">
|
: null
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="activity">
|
|
||||||
{linkedRuns && linkedRuns.length > 0 && (
|
{linkedRuns && linkedRuns.length > 0 && (
|
||||||
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
|
<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>
|
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
|
||||||
|
|
@ -1653,43 +1865,6 @@ export function IssueDetail() {
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</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 */}
|
{/* Mobile properties drawer */}
|
||||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||||
|
|
@ -1699,7 +1874,13 @@ export function IssueDetail() {
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
<ScrollArea className="flex-1 overflow-y-auto">
|
||||||
<div className="px-4 pb-4">
|
<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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue